Skip to content

Commit efe7314

Browse files
committed
feat(appkit): distinguish bootstrap vs drift in blocking-mode typegen fatal
When blocking-mode typegen fails fatally (a cache MISS with no reachable/ authorized warehouse), branch the fatal remedy on whether the committed .appkit/ cache file exists: bootstrap (never initialized → run `generate-types --wait` against a warehouse and commit .appkit/) vs drift (cache present but stale/missing the failing key → regenerate it). Same crash, clearer next step. queryCacheFileExists() is called only on the fatal path, so a warm all-hit run does no extra work. The write-then-throw contract (always emit the .d.ts, then throw to fail the build) is unchanged. Tests: all-hit blocking run makes ZERO warehouse round-trips (getWarehouse never probed); both fatal message branches asserted. Verify-at-implementation findings (no fix needed): (A) new WorkspaceClient({}) in query-registry constructs unconditionally but is lazy (no eager auth/network) — all-hit runs make zero calls, confirmed by test; (B) SDK auth errors (401/403) classify as fatal (not connectivity) via isConnectivityError, so the authz cutover crashes the build loud as intended. xavier loop: iteration 4 -- Phase 2 Wave 2 (2E) Co-authored-by: Isaac Signed-off-by: Atila Fassina <atila@fassina.eu>
1 parent 586cc87 commit efe7314

3 files changed

Lines changed: 103 additions & 5 deletions

File tree

packages/appkit/src/type-generator/index.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
loadCache,
1010
type MetricCacheEntry,
1111
metricCacheHash,
12+
queryCacheFileExists,
1213
saveCache,
1314
} from "./cache";
1415
import { getErrorDiagnostic, isConnectivityError } from "./errors";
@@ -174,7 +175,21 @@ export class TypegenSyntaxError extends Error {
174175
export class TypegenFatalError extends Error {
175176
readonly queries: QueryFatalError[];
176177

177-
constructor(queries: QueryFatalError[], warehouseId?: string) {
178+
constructor(
179+
queries: QueryFatalError[],
180+
warehouseId?: string,
181+
cacheInitialized: boolean = true,
182+
) {
183+
// Distinguish bootstrap (no committed cache yet) from drift (cache exists but is stale/missing key).
184+
// Bootstrap → operator needs to initialize; drift → operator needs to regenerate the affected key.
185+
const nextStep = cacheInitialized
186+
? // Drift: cache file exists but is missing/stale for the failing query/metric.
187+
warehouseId
188+
? `The committed ${pc.bold(".appkit/")} cache is missing or stale for the failed ${plural(queries.length, "query", "queries")}. Regenerate with ${pc.bold("generate-types --wait")} against warehouse ${pc.bold(warehouseId)} and commit ${pc.bold(".appkit/")}.`
189+
: `The committed ${pc.bold(".appkit/")} cache is missing or stale for the failed ${plural(queries.length, "query", "queries")}. Regenerate with ${pc.bold("generate-types --wait")} against a warehouse and commit ${pc.bold(".appkit/")}.`
190+
: // Bootstrap: no committed cache found; operator needs to initialize from scratch.
191+
`No committed type cache found (${pc.bold(".appkit/")}). Run ${pc.bold("generate-types --wait")} against a warehouse and commit ${pc.bold(".appkit/")} before building without warehouse access.`;
192+
178193
super(
179194
formatTypegenFailureMessage({
180195
syntaxErrors: [],
@@ -187,9 +202,7 @@ export class TypegenFatalError extends Error {
187202
"authentication failure",
188203
"SDK configuration errors",
189204
],
190-
nextStep: warehouseId
191-
? `Verify access to warehouse ${pc.bold(warehouseId)} and rerun type generation.`
192-
: "Verify warehouse access and rerun type generation.",
205+
nextStep,
193206
}),
194207
);
195208
this.name = "TypegenFatalError";
@@ -391,7 +404,8 @@ export async function generateFromEntryPoint(options: {
391404
throw new TypegenSyntaxError(syntaxErrors, warehouseId, fatalErrors);
392405
}
393406
if (fatalErrors.length > 0) {
394-
throw new TypegenFatalError(fatalErrors, warehouseId);
407+
const cacheExists = await queryCacheFileExists();
408+
throw new TypegenFatalError(fatalErrors, warehouseId, cacheExists);
395409
}
396410

397411
logger.debug("Type generation complete!");

packages/appkit/src/type-generator/tests/generate-queries.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -712,6 +712,37 @@ describe("generateQueriesFromDescribe", () => {
712712
expect(syntaxErrors).toEqual([]);
713713
});
714714

715+
test("blocking mode all-hit: makes ZERO warehouse round-trips on cache HIT", async () => {
716+
const sql1 = "SELECT id FROM t1";
717+
const sql2 = "SELECT name FROM t2";
718+
mocks.readdir.mockResolvedValue(["t1.sql", "t2.sql"]);
719+
mocks.readFile.mockResolvedValueOnce(sql1).mockResolvedValueOnce(sql2);
720+
// All queries hit the cache with matching hashes.
721+
mocks.loadCache.mockReturnValueOnce({
722+
version: CACHE_VERSION,
723+
queries: {
724+
t1: { hash: hashSQL(sql1), type: CACHED_GOOD_TYPE, retry: false },
725+
t2: { hash: hashSQL(sql2), type: CACHED_GOOD_TYPE, retry: false },
726+
},
727+
});
728+
729+
const { schemas, syntaxErrors, fatalErrors } = await describeQueries(
730+
"/queries",
731+
"wh-123",
732+
);
733+
734+
// ZERO warehouse round-trips: no preflight probe (getWarehouse never called),
735+
// no warehouse start, no DESCRIBE executions. All queries served from cache.
736+
expect(mocks.getWarehouse).not.toHaveBeenCalled();
737+
expect(mocks.startWarehouse).not.toHaveBeenCalled();
738+
expect(mocks.executeStatement).not.toHaveBeenCalled();
739+
expect(schemas).toHaveLength(2);
740+
expect(schemas[0].type).toBe(CACHED_GOOD_TYPE);
741+
expect(schemas[1].type).toBe(CACHED_GOOD_TYPE);
742+
expect(syntaxErrors).toEqual([]);
743+
expect(fatalErrors).toEqual([]);
744+
});
745+
715746
test("stale retry-flagged cache entry is re-described, not reused", async () => {
716747
const sql = "SELECT id FROM t";
717748
mocks.readdir.mockResolvedValue(["t.sql"]);

packages/appkit/src/type-generator/tests/index.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({
1717
startWarehouse: vi.fn(),
1818
waitUntilRunning: vi.fn(),
1919
executeStatement: vi.fn(),
20+
queryCacheFileExists: vi.fn(),
2021
// In-memory stand-in for the on-disk typegen cache file. `undefined` means
2122
// "no file yet"; otherwise it holds the serialized JSON exactly as
2223
// saveCache would have written it, so load/save round-trips behave like
@@ -64,6 +65,7 @@ vi.mock("../cache", async (importOriginal) => {
6465
saveCache: vi.fn(async (cache: unknown) => {
6566
mocks.cacheFile.contents = JSON.stringify(cache, null, 2);
6667
}),
68+
queryCacheFileExists: mocks.queryCacheFileExists,
6769
};
6870
});
6971

@@ -270,6 +272,57 @@ describe("generateFromEntryPoint — query failure handling", () => {
270272
expect(fs.existsSync(outFile)).toBe(true);
271273
expect(fs.readFileSync(outFile, "utf-8")).toContain("bad_auth");
272274
});
275+
276+
test("bootstrap case: distinguishes missing cache from drift in fatal message", async () => {
277+
mocks.generateQueriesFromDescribe.mockResolvedValue({
278+
schemas: [unknownSchema("query1")],
279+
syntaxErrors: [],
280+
fatalErrors: [{ name: "query1", message: "PERMISSION_DENIED" }],
281+
});
282+
// Simulate first checkout: no committed cache file yet.
283+
mocks.queryCacheFileExists.mockResolvedValue(false);
284+
285+
try {
286+
await generateFromEntryPoint({
287+
outFile,
288+
queryFolder: "/queries",
289+
warehouseId: "wh-1",
290+
});
291+
expect.fail("should have thrown TypegenFatalError");
292+
} catch (err) {
293+
expect(err).toBeInstanceOf(TypegenFatalError);
294+
expect((err as Error).message).toMatch(/No committed type cache found/i);
295+
expect((err as Error).message).toMatch(/generate-types --wait/i);
296+
expect((err as Error).message).toMatch(/commit \.appkit\//i);
297+
}
298+
});
299+
300+
test("drift case: cache exists but is stale/missing for failing query", async () => {
301+
mocks.generateQueriesFromDescribe.mockResolvedValue({
302+
schemas: [unknownSchema("query1")],
303+
syntaxErrors: [],
304+
fatalErrors: [{ name: "query1", message: "PERMISSION_DENIED" }],
305+
});
306+
// Cache file exists but is out of date (the query's key is missing or stale).
307+
mocks.queryCacheFileExists.mockResolvedValue(true);
308+
309+
try {
310+
await generateFromEntryPoint({
311+
outFile,
312+
queryFolder: "/queries",
313+
warehouseId: "wh-1",
314+
});
315+
expect.fail("should have thrown TypegenFatalError");
316+
} catch (err) {
317+
expect(err).toBeInstanceOf(TypegenFatalError);
318+
expect((err as Error).message).toMatch(/missing or stale/i);
319+
expect((err as Error).message).toMatch(/Regenerate with/i);
320+
expect((err as Error).message).toMatch(/generate-types --wait/i);
321+
expect((err as Error).message).not.toMatch(
322+
/No committed type cache found/i,
323+
);
324+
}
325+
});
273326
});
274327

275328
describe("generateFromEntryPoint — metric-view emission", () => {

0 commit comments

Comments
 (0)