Skip to content

Commit 586cc87

Browse files
committed
feat(appkit): relocate typegen caches to committed .appkit/ dir
Move the query/metric cache (types-cache.json) and serving cache (serving-types-cache.json) out of node_modules/.databricks/appkit into the committed per-app .appkit/ directory via getCommittedCacheDir(), so they survive `pnpm install` and let a blocking CI/CD build succeed with no warehouse access. Flat filenames (the dir namespaces them). Both writers now sort top-level record keys for deterministic, merge-friendly diffs. The serving plugin's runtime read now resolves through the same exported getServingCachePath() the writer uses (reader == writer by construction, fixing the inline-path drift risk). Add queryCacheFileExists() for the blocking-mode bootstrap-vs-drift message (consumed next). Route the ephemeral ui-variant choices file through getEphemeralStateDir() (path unchanged; stays gitignored). Document .appkit/ as a committed artifact in the template. xavier loop: iteration 3 -- Phase 2 Wave 1 (2A, 2B+2C, 2D) + Phase 3A Co-authored-by: Isaac Signed-off-by: Atila Fassina <atila@fassina.eu>
1 parent 9107712 commit 586cc87

8 files changed

Lines changed: 328 additions & 57 deletions

File tree

packages/appkit/src/plugins/serving/serving.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import path from "node:path";
21
import { Readable } from "node:stream";
32
import { pipeline } from "node:stream/promises";
43
import type express from "express";
@@ -9,6 +8,7 @@ import { createLogger } from "../../logging";
98
import { type ExecutionResult, Plugin, toPlugin } from "../../plugin";
109
import type { PluginManifest, ResourceRequirement } from "../../registry";
1110
import { ResourceType } from "../../registry";
11+
import { getServingCachePath } from "../../type-generator/serving/cache";
1212
import { servingInvokeDefaults } from "./defaults";
1313
import manifest from "./manifest.json";
1414
import { filterRequestBody, loadEndpointSchemas } from "./schema-filter";
@@ -66,14 +66,7 @@ export class ServingPlugin extends Plugin {
6666
}
6767

6868
async setup(): Promise<void> {
69-
const cacheFile = path.join(
70-
process.cwd(),
71-
"node_modules",
72-
".databricks",
73-
"appkit",
74-
".appkit-serving-types-cache.json",
75-
);
76-
this.schemaAllowlists = await loadEndpointSchemas(cacheFile);
69+
this.schemaAllowlists = await loadEndpointSchemas(getServingCachePath());
7770
if (this.schemaAllowlists.size > 0) {
7871
logger.debug(
7972
"Loaded schema allowlists for %d endpoint(s)",

packages/appkit/src/plugins/ui-variants/choice-sink.ts

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,31 @@
11
import fs from "node:fs/promises";
22
import path from "node:path";
3+
import { getEphemeralStateDir } from "shared";
34

45
/**
5-
* Path of the JSONL choices file, relative to the app's working directory.
6-
* A coding agent reads it to pick up the developer's in-browser confirmation
7-
* and finalize the chosen variant.
6+
* Filename of the JSONL choices file. The agent skill discovers this file at
7+
* the ephemeral state directory (under node_modules/).
8+
*/
9+
const UI_CHOICES_FILENAME = ".appkit-ui-choices.jsonl";
10+
11+
/**
12+
* Default absolute path to the JSONL choices file: ephemeral state dir
13+
* (node_modules/.databricks/appkit/) + the contract filename. Kept
14+
* ephemeral & gitignored; NOT part of the committed .appkit/ relocation.
815
*
9-
* Under `node_modules/`, so it's gitignored and cleared on a clean install.
10-
* Each line is spent on read — the agent removes it once the choice is
11-
* finalized.
16+
* A coding agent reads it to pick up the developer's in-browser confirmation
17+
* and finalize the chosen variant. Each line is spent on read — the agent
18+
* removes it once the choice is finalized.
1219
*
1320
* CONTRACT: the `databricks-app-variants` agent skill (in the
14-
* databricks-agent-skills repo) discovers the choices file at this path.
15-
* Changing this value silently breaks that skill's file discovery — update the
16-
* skill's `find` path in the same change.
21+
* databricks-agent-skills repo) discovers the choices file at this absolute
22+
* path. Changing this value silently breaks that skill's file discovery —
23+
* update the skill's `find` path in the same change.
1724
*/
18-
const UI_CHOICES_FILE =
19-
"node_modules/.databricks/appkit/.appkit-ui-choices.jsonl";
25+
const DEFAULT_UI_CHOICES_PATH = path.join(
26+
getEphemeralStateDir(),
27+
UI_CHOICES_FILENAME,
28+
);
2029

2130
/**
2231
* One recorded variant choice.
@@ -42,14 +51,16 @@ export interface UiChoiceRecord {
4251

4352
/**
4453
* File store for confirmed variant choices: upserts choices into
45-
* {@link UI_CHOICES_FILE}, one line per `<Variants>` id.
54+
* {@link DEFAULT_UI_CHOICES_PATH}, one line per `<Variants>` id.
4655
*
4756
* The store is **keyed and latest-wins**: at most one record per `blockId`, and
4857
* recording an existing `blockId` replaces it rather than appending, so the file
4958
* always reflects the current choice for each block.
5059
*
51-
* The file is resolved against `process.cwd()`, so it lands under whatever
52-
* directory the dev server runs from. Concurrent confirms are serialized behind
60+
* By default, the file lands at the ephemeral state directory under
61+
* `node_modules/.databricks/appkit/`. Callers may override the path (e.g., for
62+
* testing), in which case it is resolved against `process.cwd()`; absolute
63+
* paths are passed through unchanged. Concurrent confirms are serialized behind
5364
* an internal queue so their read-modify-write can't interleave and lose an
5465
* update.
5566
*
@@ -59,8 +70,8 @@ export class FileChoiceStore {
5970
private readonly filePath: string;
6071
private writeQueue: Promise<void> = Promise.resolve();
6172

62-
constructor(relativePath: string = UI_CHOICES_FILE) {
63-
this.filePath = path.resolve(process.cwd(), relativePath);
73+
constructor(pathArg: string = DEFAULT_UI_CHOICES_PATH) {
74+
this.filePath = path.resolve(process.cwd(), pathArg);
6475
}
6576

6677
record(record: UiChoiceRecord): Promise<void> {

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

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import crypto from "node:crypto";
22
import fs from "node:fs/promises";
33
import path from "node:path";
4+
import { getCommittedCacheDir } from "shared";
45
import { createLogger } from "../logging/logger";
56
import type { MetricSchema } from "./mv-registry/types";
67

@@ -38,7 +39,7 @@ export interface MetricCacheEntry {
3839
/**
3940
* Structural gate for reviving a cached metric entry at partition time.
4041
*
41-
* The cache file lives in `node_modules/.databricks` and is plain JSON —
42+
* The cache file lives in the committed `.appkit/` dir and is plain JSON —
4243
* hand-edits, truncation, or a stale writer can leave entries whose shape no
4344
* longer matches {@link MetricCacheEntry}. A malformed entry must read as a
4445
* cache MISS (re-describe) rather than crash the pass or render revived
@@ -87,13 +88,7 @@ interface Cache {
8788
}
8889

8990
export const CACHE_VERSION = "3";
90-
const CACHE_FILE = ".appkit-types-cache.json";
91-
const CACHE_DIR = path.join(
92-
process.cwd(),
93-
"node_modules",
94-
".databricks",
95-
"appkit",
96-
);
91+
const CACHE_FILE = "types-cache.json";
9792

9893
/**
9994
* Hash the SQL query
@@ -120,9 +115,10 @@ export function metricCacheHash(source: string, lane: string): string {
120115
* @returns - the cache
121116
*/
122117
export async function loadCache(): Promise<Cache> {
123-
const cachePath = path.join(CACHE_DIR, CACHE_FILE);
118+
const cacheDir = getCommittedCacheDir();
119+
const cachePath = path.join(cacheDir, CACHE_FILE);
124120
try {
125-
await fs.mkdir(CACHE_DIR, { recursive: true });
121+
await fs.mkdir(cacheDir, { recursive: true });
126122

127123
const raw = await fs.readFile(cachePath, "utf8");
128124
const cache = JSON.parse(raw) as Cache;
@@ -138,10 +134,53 @@ export async function loadCache(): Promise<Cache> {
138134
}
139135

140136
/**
141-
* Save the cache to the file system
137+
* Save the cache to the file system with deterministic serialization
138+
* (sorted top-level keys for no-op regen diffs and merge-friendly output).
142139
* @param cache - cache object to save
143140
*/
144141
export async function saveCache(cache: Cache): Promise<void> {
145-
const cachePath = path.join(CACHE_DIR, CACHE_FILE);
146-
await fs.writeFile(cachePath, JSON.stringify(cache, null, 2), "utf8");
142+
const cacheDir = getCommittedCacheDir();
143+
const cachePath = path.join(cacheDir, CACHE_FILE);
144+
145+
// Ensure the cache directory exists
146+
await fs.mkdir(cacheDir, { recursive: true });
147+
148+
// Rebuild cache with sorted top-level record keys for deterministic output.
149+
// This ensures a no-op regen produces an identical file (no spurious diffs)
150+
// and unrelated query/metric changes don't collide in git merges.
151+
const sorted: Cache = {
152+
version: cache.version,
153+
queries: Object.fromEntries(
154+
Object.keys(cache.queries)
155+
.sort()
156+
.map((key) => [key, cache.queries[key]]),
157+
),
158+
};
159+
160+
// Include sorted metrics if present
161+
if (cache.metrics) {
162+
const metrics = cache.metrics;
163+
sorted.metrics = Object.fromEntries(
164+
Object.keys(metrics)
165+
.sort()
166+
.map((key) => [key, metrics[key]]),
167+
);
168+
}
169+
170+
await fs.writeFile(cachePath, JSON.stringify(sorted, null, 2), "utf8");
171+
}
172+
173+
/**
174+
* Whether the committed query/metric cache file exists on disk. Lets the
175+
* blocking-mode fatal path distinguish an uninitialized cache (bootstrap:
176+
* run `generate-types --wait` against a warehouse and commit `.appkit/`) from
177+
* a drifted key (regenerate the affected query/metric).
178+
*/
179+
export async function queryCacheFileExists(): Promise<boolean> {
180+
try {
181+
await fs.access(path.join(getCommittedCacheDir(), CACHE_FILE));
182+
return true;
183+
} catch {
184+
return false;
185+
}
147186
}

packages/appkit/src/type-generator/serving/cache.ts

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,13 @@
11
import crypto from "node:crypto";
22
import fs from "node:fs/promises";
33
import path from "node:path";
4+
import { getCommittedCacheDir } from "shared";
45
import { createLogger } from "../../logging/logger";
56

67
const logger = createLogger("type-generator:serving:cache");
78

89
export const CACHE_VERSION = "1";
9-
const CACHE_FILE = ".appkit-serving-types-cache.json";
10-
const CACHE_DIR = path.join(
11-
process.cwd(),
12-
"node_modules",
13-
".databricks",
14-
"appkit",
15-
);
10+
const CACHE_FILE = "serving-types-cache.json";
1611

1712
export interface ServingCacheEntry {
1813
hash: string;
@@ -27,14 +22,24 @@ export interface ServingCache {
2722
endpoints: Record<string, ServingCacheEntry>;
2823
}
2924

25+
/**
26+
* Absolute path of the committed serving-types cache. The single source of
27+
* truth shared by the writer (this module) and the runtime reader
28+
* (serving plugin `setup()`), so a relocation can never desync them.
29+
*/
30+
export function getServingCachePath(): string {
31+
return path.join(getCommittedCacheDir(), CACHE_FILE);
32+
}
33+
3034
export function hashSchema(schemaJson: string): string {
3135
return crypto.createHash("sha256").update(schemaJson).digest("hex");
3236
}
3337

3438
export async function loadServingCache(): Promise<ServingCache> {
35-
const cachePath = path.join(CACHE_DIR, CACHE_FILE);
39+
const cachePath = getServingCachePath();
40+
const cacheDir = getCommittedCacheDir();
3641
try {
37-
await fs.mkdir(CACHE_DIR, { recursive: true });
42+
await fs.mkdir(cacheDir, { recursive: true });
3843
const raw = await fs.readFile(cachePath, "utf8");
3944
const cache = JSON.parse(raw) as ServingCache;
4045
if (cache.version === CACHE_VERSION) {
@@ -50,7 +55,23 @@ export async function loadServingCache(): Promise<ServingCache> {
5055
}
5156

5257
export async function saveServingCache(cache: ServingCache): Promise<void> {
53-
const cachePath = path.join(CACHE_DIR, CACHE_FILE);
54-
await fs.mkdir(CACHE_DIR, { recursive: true });
55-
await fs.writeFile(cachePath, JSON.stringify(cache, null, 2), "utf8");
58+
const cachePath = getServingCachePath();
59+
const cacheDir = getCommittedCacheDir();
60+
await fs.mkdir(cacheDir, { recursive: true });
61+
62+
// Sort endpoint keys for deterministic output (merge-friendly diffs)
63+
const sortedCache: ServingCache = {
64+
version: cache.version,
65+
endpoints: Object.keys(cache.endpoints)
66+
.sort()
67+
.reduce(
68+
(acc, key) => {
69+
acc[key] = cache.endpoints[key];
70+
return acc;
71+
},
72+
{} as Record<string, (typeof cache.endpoints)[string]>,
73+
),
74+
};
75+
76+
await fs.writeFile(cachePath, JSON.stringify(sortedCache, null, 2), "utf8");
5677
}

packages/appkit/src/type-generator/serving/tests/cache.test.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,27 +81,43 @@ describe("serving cache", () => {
8181
});
8282

8383
describe("saveServingCache", () => {
84-
test("writes cache to file", async () => {
84+
test("writes cache to file with sorted endpoints", async () => {
8585
vi.mocked(fs.writeFile).mockResolvedValue();
8686

8787
const cache: ServingCache = {
8888
version: CACHE_VERSION,
8989
endpoints: {
90-
test: {
90+
zebra: {
9191
hash: "xyz",
9292
requestType: "{}",
9393
responseType: "{}",
9494
chunkType: null,
9595
requestKeys: [],
9696
},
97+
apple: {
98+
hash: "abc",
99+
requestType: "{}",
100+
responseType: "{}",
101+
chunkType: null,
102+
requestKeys: [],
103+
},
97104
},
98105
};
99106

100107
await saveServingCache(cache);
101108

109+
// Endpoints should be sorted alphabetically in the output
110+
const expectedSorted: ServingCache = {
111+
version: CACHE_VERSION,
112+
endpoints: {
113+
apple: cache.endpoints.apple,
114+
zebra: cache.endpoints.zebra,
115+
},
116+
};
117+
102118
expect(fs.writeFile).toHaveBeenCalledWith(
103-
expect.stringContaining(".appkit-serving-types-cache.json"),
104-
JSON.stringify(cache, null, 2),
119+
expect.stringContaining("serving-types-cache.json"),
120+
JSON.stringify(expectedSorted, null, 2),
105121
"utf8",
106122
);
107123
});

0 commit comments

Comments
 (0)