From ac38a6b54caf552ba163e28d5be198715b881e76 Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Wed, 15 Apr 2026 18:10:12 +0200 Subject: [PATCH 01/11] fix: add AST extraction to serving type generator and move types to shared/ The CLI generate-types command was falling back to the DATABRICKS_SERVING_ENDPOINT_NAME env var instead of extracting endpoint aliases from server.ts, producing a single "default" key instead of the actual aliases (e.g., "first", "second", "third"). This causes build failures when client code references typed endpoint aliases. Additionally, generated type declarations are moved from client/src/appkit-types/ to shared/appkit-types/ so both server and client tsconfigs can see the ServingEndpointRegistry module augmentation (server tsconfig already includes shared/**/*). Co-authored-by: Isaac --- apps/dev-playground/client/tsconfig.app.json | 2 +- .../appkit-types/analytics.d.ts | 0 .../src/type-generator/serving/generator.ts | 23 +++++++++++++++++-- .../src/type-generator/serving/vite-plugin.ts | 4 ++-- .../appkit/src/type-generator/vite-plugin.ts | 5 ++-- .../shared/src/cli/commands/generate-types.ts | 6 ++--- template/tsconfig.client.json | 2 +- 7 files changed, 31 insertions(+), 11 deletions(-) rename apps/dev-playground/{client/src => shared}/appkit-types/analytics.d.ts (100%) diff --git a/apps/dev-playground/client/tsconfig.app.json b/apps/dev-playground/client/tsconfig.app.json index 5fab448d7..6e2a3b464 100644 --- a/apps/dev-playground/client/tsconfig.app.json +++ b/apps/dev-playground/client/tsconfig.app.json @@ -29,5 +29,5 @@ "@/*": ["./src/*"] } }, - "include": ["src"] + "include": ["src", "../shared/appkit-types"] } diff --git a/apps/dev-playground/client/src/appkit-types/analytics.d.ts b/apps/dev-playground/shared/appkit-types/analytics.d.ts similarity index 100% rename from apps/dev-playground/client/src/appkit-types/analytics.d.ts rename to apps/dev-playground/shared/appkit-types/analytics.d.ts diff --git a/packages/appkit/src/type-generator/serving/generator.ts b/packages/appkit/src/type-generator/serving/generator.ts index 6adf4a28c..1f7e37509 100644 --- a/packages/appkit/src/type-generator/serving/generator.ts +++ b/packages/appkit/src/type-generator/serving/generator.ts @@ -18,6 +18,10 @@ import { extractRequestKeys, } from "./converter"; import { fetchOpenApiSchema } from "./fetcher"; +import { + extractServingEndpoints, + findServerFile, +} from "./server-file-extractor"; const logger = createLogger("type-generator:serving"); @@ -34,14 +38,21 @@ interface GenerateServingTypesOptions { /** * Generates TypeScript type declarations for serving endpoints * by fetching their OpenAPI schemas and converting to TypeScript. + * + * Endpoint discovery order (when `endpoints` is not provided): + * 1. AST extraction from server file (server/index.ts or server/server.ts) + * 2. DATABRICKS_SERVING_ENDPOINT_NAME env var (single default endpoint) */ export async function generateServingTypes( options: GenerateServingTypesOptions, ): Promise { const { outFile, noCache } = options; - // Resolve endpoints from config or env - const endpoints = options.endpoints ?? resolveDefaultEndpoints(); + // Resolve endpoints: explicit > AST extraction from server file > env var fallback + const endpoints = + options.endpoints ?? + resolveEndpointsFromServerFile() ?? + resolveDefaultEndpoints(); if (Object.keys(endpoints).length === 0) { logger.debug("No serving endpoints configured, skipping type generation"); return; @@ -227,6 +238,14 @@ function printLogTable( console.log(""); } +function resolveEndpointsFromServerFile(): + | Record + | undefined { + const serverFile = findServerFile(process.cwd()); + if (!serverFile) return undefined; + return extractServingEndpoints(serverFile) ?? undefined; +} + function resolveDefaultEndpoints(): Record { if (process.env.DATABRICKS_SERVING_ENDPOINT_NAME) { return { default: { env: "DATABRICKS_SERVING_ENDPOINT_NAME" } }; diff --git a/packages/appkit/src/type-generator/serving/vite-plugin.ts b/packages/appkit/src/type-generator/serving/vite-plugin.ts index 3e3e2d8f8..7542a4598 100644 --- a/packages/appkit/src/type-generator/serving/vite-plugin.ts +++ b/packages/appkit/src/type-generator/serving/vite-plugin.ts @@ -95,8 +95,8 @@ export function appKitServingTypesPlugin( // - pnpm build: process.cwd() is client/ (cd client && vite build), config.root is client/ projectRoot = path.resolve(config.root, ".."); outFile = path.resolve( - config.root, - options?.outFile ?? `src/${TYPES_DIR}/${SERVING_TYPES_FILE}`, + projectRoot, + options?.outFile ?? `shared/${TYPES_DIR}/${SERVING_TYPES_FILE}`, ); }, diff --git a/packages/appkit/src/type-generator/vite-plugin.ts b/packages/appkit/src/type-generator/vite-plugin.ts index 3eccb8ad4..963c9bb78 100644 --- a/packages/appkit/src/type-generator/vite-plugin.ts +++ b/packages/appkit/src/type-generator/vite-plugin.ts @@ -75,9 +75,10 @@ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { configResolved(config) { root = config.root; + const projectRoot = path.resolve(config.root, ".."); outFile = path.resolve( - root, - options?.outFile ?? `src/${TYPES_DIR}/${ANALYTICS_TYPES_FILE}`, + projectRoot, + options?.outFile ?? `shared/${TYPES_DIR}/${ANALYTICS_TYPES_FILE}`, ); watchFolders = options?.watchFolders ?? [ path.join(process.cwd(), "config", "queries"), diff --git a/packages/shared/src/cli/commands/generate-types.ts b/packages/shared/src/cli/commands/generate-types.ts index 6bb5a8bba..6fab73fc3 100644 --- a/packages/shared/src/cli/commands/generate-types.ts +++ b/packages/shared/src/cli/commands/generate-types.ts @@ -24,7 +24,7 @@ async function runGenerateTypes( if (resolvedWarehouseId) { const resolvedOutFile = outFile || - path.join(process.cwd(), "client/src/appkit-types/analytics.d.ts"); + path.join(process.cwd(), "shared/appkit-types/analytics.d.ts"); const queryFolder = path.join(resolvedRootDir, "config/queries"); if (fs.existsSync(queryFolder)) { @@ -45,7 +45,7 @@ async function runGenerateTypes( // Generate serving endpoint types (no warehouse required) const servingOutFile = path.join( process.cwd(), - "client/src/appkit-types/serving.d.ts", + "shared/appkit-types/serving.d.ts", ); await typeGen.generateServingTypes({ outFile: servingOutFile, @@ -73,7 +73,7 @@ export const generateTypesCommand = new Command("generate-types") .argument( "[outFile]", "Output file path", - path.join(process.cwd(), "client/src/appkit-types/analytics.d.ts"), + path.join(process.cwd(), "shared/appkit-types/analytics.d.ts"), ) .argument("[warehouseId]", "Databricks warehouse ID") .option("--no-cache", "Disable caching for type generation") diff --git a/template/tsconfig.client.json b/template/tsconfig.client.json index 8732ba46b..a183fcac5 100644 --- a/template/tsconfig.client.json +++ b/template/tsconfig.client.json @@ -24,5 +24,5 @@ "@/*": ["./src/*"] } }, - "include": ["client/src"] + "include": ["client/src", "shared/appkit-types"] } From c7b8207e7b433a0576ae665c27398f891e4081d0 Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Thu, 16 Apr 2026 12:51:44 +0200 Subject: [PATCH 02/11] fix: update serving vite-plugin test for shared/ output path The outFile path changed from client/src/ to shared/ but the test assertion was not updated. Signed-off-by: Pawel Kosiec --- .../src/type-generator/serving/tests/vite-plugin.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/appkit/src/type-generator/serving/tests/vite-plugin.test.ts b/packages/appkit/src/type-generator/serving/tests/vite-plugin.test.ts index 889bee5b6..1d4ad6653 100644 --- a/packages/appkit/src/type-generator/serving/tests/vite-plugin.test.ts +++ b/packages/appkit/src/type-generator/serving/tests/vite-plugin.test.ts @@ -69,7 +69,7 @@ describe("appKitServingTypesPlugin", () => { }); describe("configResolved()", () => { - test("resolves outFile relative to config.root", async () => { + test("resolves outFile relative to project root", async () => { const plugin = appKitServingTypesPlugin({ endpoints: { llm: { env: "LLM" } }, }); @@ -79,7 +79,7 @@ describe("appKitServingTypesPlugin", () => { expect(mockGenerateServingTypes).toHaveBeenCalledWith( expect.objectContaining({ outFile: expect.stringContaining( - "/app/client/src/appkit-types/serving.d.ts", + "/app/shared/appkit-types/serving.d.ts", ), }), ); From 9d7072e38edcf8edce3064ac4a8e0698592fb4de Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Thu, 16 Apr 2026 13:55:16 +0200 Subject: [PATCH 03/11] fix: update gitignore for shared/ types and add tsc to template server build - Move serving types gitignore from client/src/ to shared/ (dev-playground + template) - Add old file cleanup in generator to prevent duplicate module augmentation - Add tsc -b to template build:server for type-checking parity with build:client - Simplify template typecheck script to use tsc -b consistently Signed-off-by: Pawel Kosiec --- apps/dev-playground/.gitignore | 5 +++- apps/dev-playground/client/.gitignore | 3 --- .../src/type-generator/serving/generator.ts | 26 +++++++++++++++++++ template/_gitignore | 3 +++ template/package.json | 4 +-- template/tsconfig.server.json | 1 + 6 files changed, 36 insertions(+), 6 deletions(-) diff --git a/apps/dev-playground/.gitignore b/apps/dev-playground/.gitignore index 5c957fcd6..1f4745f52 100644 --- a/apps/dev-playground/.gitignore +++ b/apps/dev-playground/.gitignore @@ -1,3 +1,6 @@ # Playwright test-results/ -playwright-report/ \ No newline at end of file +playwright-report/ + +# Auto-generated types (endpoint-specific, varies per developer) +shared/appkit-types/serving.d.ts \ No newline at end of file diff --git a/apps/dev-playground/client/.gitignore b/apps/dev-playground/client/.gitignore index 19067f126..a547bf36d 100644 --- a/apps/dev-playground/client/.gitignore +++ b/apps/dev-playground/client/.gitignore @@ -12,9 +12,6 @@ dist dist-ssr *.local -# Auto-generated types (endpoint-specific, varies per developer) -src/appkit-types/serving.d.ts - # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/packages/appkit/src/type-generator/serving/generator.ts b/packages/appkit/src/type-generator/serving/generator.ts index 1f7e37509..0f01be076 100644 --- a/packages/appkit/src/type-generator/serving/generator.ts +++ b/packages/appkit/src/type-generator/serving/generator.ts @@ -88,6 +88,9 @@ export async function generateServingTypes( await fs.mkdir(path.dirname(outFile), { recursive: true }); await fs.writeFile(outFile, output, "utf-8"); + // One-time migration: remove old generated file from client/src/ to avoid duplicate module augmentation + await removeOldServingTypes(outFile); + if (registryEntries.length === 0) { logger.debug( "Wrote empty serving types to %s (no endpoints resolved)", @@ -246,6 +249,29 @@ function resolveEndpointsFromServerFile(): return extractServingEndpoints(serverFile) ?? undefined; } +/** + * Remove old serving types from client/src/appkit-types/ (pre-shared/ location). + * Best-effort: silently ignores missing files. + */ +async function removeOldServingTypes(newOutFile: string): Promise { + // newOutFile is like /shared/appkit-types/serving.d.ts + // old location was /client/src/appkit-types/serving.d.ts + const projectRoot = path.resolve(path.dirname(newOutFile), "..", ".."); + const oldFile = path.join( + projectRoot, + "client", + "src", + "appkit-types", + "serving.d.ts", + ); + try { + await fs.unlink(oldFile); + logger.debug("Removed old serving types at %s", oldFile); + } catch { + // File doesn't exist — nothing to clean up + } +} + function resolveDefaultEndpoints(): Record { if (process.env.DATABRICKS_SERVING_ENDPOINT_NAME) { return { default: { env: "DATABRICKS_SERVING_ENDPOINT_NAME" } }; diff --git a/template/_gitignore b/template/_gitignore index f2abc3263..23adbc24e 100644 --- a/template/_gitignore +++ b/template/_gitignore @@ -8,3 +8,6 @@ build/ .smoke-test/ test-results/ playwright-report/ + +# Auto-generated types (endpoint-specific, varies per developer) +shared/appkit-types/serving.d.ts diff --git a/template/package.json b/template/package.json index 136897dd8..584299afd 100644 --- a/template/package.json +++ b/template/package.json @@ -7,9 +7,9 @@ "start": "NODE_ENV=production node --env-file-if-exists=./.env ./dist/server.js", "dev": "NODE_ENV=development tsx watch --tsconfig ./tsconfig.server.json --env-file-if-exists=./.env ./server/server.ts", "build:client": "tsc -b tsconfig.client.json && vite build --config client/vite.config.ts", - "build:server": "tsdown -c tsdown.server.config.ts", + "build:server": "tsc -b tsconfig.server.json && tsdown -c tsdown.server.config.ts", "build": "npm run build:server && npm run build:client", - "typecheck": "tsc -p ./tsconfig.server.json --noEmit && tsc -p ./tsconfig.client.json --noEmit", + "typecheck": "tsc -b tsconfig.server.json && tsc -b tsconfig.client.json", "lint": "eslint .", "lint:fix": "eslint . --fix", "lint:ast-grep": "appkit lint", diff --git a/template/tsconfig.server.json b/template/tsconfig.server.json index 8cdada22c..4273c9b27 100644 --- a/template/tsconfig.server.json +++ b/template/tsconfig.server.json @@ -6,6 +6,7 @@ "lib": ["ES2020"], /* Emit */ + "noEmit": true, "outDir": "./dist", "rootDir": "./", "declaration": true, From 3a581359136e7dfbba2223453b00d3a0b55d71c0 Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Thu, 16 Apr 2026 14:07:25 +0200 Subject: [PATCH 04/11] fix: add old file cleanup for analytics types too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the serving types cleanup — remove stale client/src/appkit-types/analytics.d.ts after generating at the new shared/ location. Signed-off-by: Pawel Kosiec --- packages/appkit/src/type-generator/index.ts | 24 +++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/appkit/src/type-generator/index.ts b/packages/appkit/src/type-generator/index.ts index a1e04025a..eba7647b6 100644 --- a/packages/appkit/src/type-generator/index.ts +++ b/packages/appkit/src/type-generator/index.ts @@ -87,6 +87,9 @@ export async function generateFromEntryPoint(options: { await fs.mkdir(path.dirname(outFile), { recursive: true }); await fs.writeFile(outFile, typeDeclarations, "utf-8"); + // One-time migration: remove old generated file from client/src/ to avoid duplicate module augmentation + await removeOldAnalyticsTypes(outFile); + logger.debug("Type generation complete!"); } @@ -95,6 +98,27 @@ export async function generateFromEntryPoint(options: { // mirroring how generateFromEntryPoint (also defined here) is preserved via the analytics vite plugin. export const generateServingTypes = generateServingTypesImpl; +/** + * Remove old analytics types from client/src/appkit-types/ (pre-shared/ location). + * Best-effort: silently ignores missing files. + */ +async function removeOldAnalyticsTypes(newOutFile: string): Promise { + const projectRoot = path.resolve(path.dirname(newOutFile), "..", ".."); + const oldFile = path.join( + projectRoot, + "client", + "src", + "appkit-types", + "analytics.d.ts", + ); + try { + await fs.unlink(oldFile); + logger.debug("Removed old analytics types at %s", oldFile); + } catch { + // File doesn't exist — nothing to clean up + } +} + /** Directory name for generated AppKit type declaration files. */ export const TYPES_DIR = "appkit-types"; /** Default filename for analytics query type declarations. */ From a2e1fc2fee8bd52c3d8d2f1d6ce56fbe7a72b3e9 Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Thu, 16 Apr 2026 15:36:48 +0200 Subject: [PATCH 05/11] fix: deduplicate migration cleanup, add error handling, remove dead code - Extract removeOldGeneratedTypes into migration.ts to avoid duplication and circular dependency between index.ts and serving/generator.ts - Add try-catch to resolveEndpointsFromServerFile so AST parse failures fall back gracefully to env var - Remove unused root variable in analytics vite-plugin - Clean up template tsconfig.server.json: remove dead emit options (outDir, declaration, declarationMap, sourceMap) since noEmit is set Signed-off-by: Pawel Kosiec --- packages/appkit/src/type-generator/index.ts | 24 ++----------- .../appkit/src/type-generator/migration.ts | 29 +++++++++++++++ .../src/type-generator/serving/generator.ts | 36 ++++++------------- .../appkit/src/type-generator/vite-plugin.ts | 2 -- template/tsconfig.server.json | 8 +---- 5 files changed, 43 insertions(+), 56 deletions(-) create mode 100644 packages/appkit/src/type-generator/migration.ts diff --git a/packages/appkit/src/type-generator/index.ts b/packages/appkit/src/type-generator/index.ts index eba7647b6..1acd39d8f 100644 --- a/packages/appkit/src/type-generator/index.ts +++ b/packages/appkit/src/type-generator/index.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import dotenv from "dotenv"; import { createLogger } from "../logging/logger"; +import { removeOldGeneratedTypes } from "./migration"; import { generateQueriesFromDescribe } from "./query-registry"; import { generateServingTypes as generateServingTypesImpl } from "./serving/generator"; import type { QuerySchema } from "./types"; @@ -88,7 +89,7 @@ export async function generateFromEntryPoint(options: { await fs.writeFile(outFile, typeDeclarations, "utf-8"); // One-time migration: remove old generated file from client/src/ to avoid duplicate module augmentation - await removeOldAnalyticsTypes(outFile); + await removeOldGeneratedTypes(outFile, "analytics.d.ts"); logger.debug("Type generation complete!"); } @@ -98,27 +99,6 @@ export async function generateFromEntryPoint(options: { // mirroring how generateFromEntryPoint (also defined here) is preserved via the analytics vite plugin. export const generateServingTypes = generateServingTypesImpl; -/** - * Remove old analytics types from client/src/appkit-types/ (pre-shared/ location). - * Best-effort: silently ignores missing files. - */ -async function removeOldAnalyticsTypes(newOutFile: string): Promise { - const projectRoot = path.resolve(path.dirname(newOutFile), "..", ".."); - const oldFile = path.join( - projectRoot, - "client", - "src", - "appkit-types", - "analytics.d.ts", - ); - try { - await fs.unlink(oldFile); - logger.debug("Removed old analytics types at %s", oldFile); - } catch { - // File doesn't exist — nothing to clean up - } -} - /** Directory name for generated AppKit type declaration files. */ export const TYPES_DIR = "appkit-types"; /** Default filename for analytics query type declarations. */ diff --git a/packages/appkit/src/type-generator/migration.ts b/packages/appkit/src/type-generator/migration.ts new file mode 100644 index 000000000..b9f73b089 --- /dev/null +++ b/packages/appkit/src/type-generator/migration.ts @@ -0,0 +1,29 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { createLogger } from "../logging/logger"; + +const logger = createLogger("type-generator:migration"); + +/** + * Remove old generated types from client/src/appkit-types/ (pre-shared/ location). + * Best-effort: silently ignores missing files. + */ +export async function removeOldGeneratedTypes( + newOutFile: string, + filename: string, +): Promise { + const projectRoot = path.resolve(path.dirname(newOutFile), "..", ".."); + const oldFile = path.join( + projectRoot, + "client", + "src", + "appkit-types", + filename, + ); + try { + await fs.unlink(oldFile); + logger.debug("Removed old types at %s", oldFile); + } catch { + // File doesn't exist — nothing to clean up + } +} diff --git a/packages/appkit/src/type-generator/serving/generator.ts b/packages/appkit/src/type-generator/serving/generator.ts index 0f01be076..e6ff69920 100644 --- a/packages/appkit/src/type-generator/serving/generator.ts +++ b/packages/appkit/src/type-generator/serving/generator.ts @@ -4,6 +4,7 @@ import { WorkspaceClient } from "@databricks/sdk-experimental"; import pc from "picocolors"; import { createLogger } from "../../logging/logger"; import type { EndpointConfig } from "../../plugins/serving/types"; +import { removeOldGeneratedTypes } from "../migration"; import { CACHE_VERSION, hashSchema, @@ -89,7 +90,7 @@ export async function generateServingTypes( await fs.writeFile(outFile, output, "utf-8"); // One-time migration: remove old generated file from client/src/ to avoid duplicate module augmentation - await removeOldServingTypes(outFile); + await removeOldGeneratedTypes(outFile, "serving.d.ts"); if (registryEntries.length === 0) { logger.debug( @@ -244,31 +245,16 @@ function printLogTable( function resolveEndpointsFromServerFile(): | Record | undefined { - const serverFile = findServerFile(process.cwd()); - if (!serverFile) return undefined; - return extractServingEndpoints(serverFile) ?? undefined; -} - -/** - * Remove old serving types from client/src/appkit-types/ (pre-shared/ location). - * Best-effort: silently ignores missing files. - */ -async function removeOldServingTypes(newOutFile: string): Promise { - // newOutFile is like /shared/appkit-types/serving.d.ts - // old location was /client/src/appkit-types/serving.d.ts - const projectRoot = path.resolve(path.dirname(newOutFile), "..", ".."); - const oldFile = path.join( - projectRoot, - "client", - "src", - "appkit-types", - "serving.d.ts", - ); try { - await fs.unlink(oldFile); - logger.debug("Removed old serving types at %s", oldFile); - } catch { - // File doesn't exist — nothing to clean up + const serverFile = findServerFile(process.cwd()); + if (!serverFile) return undefined; + return extractServingEndpoints(serverFile) ?? undefined; + } catch (error) { + logger.debug( + "Failed to extract endpoints from server file: %s", + (error as Error).message, + ); + return undefined; } } diff --git a/packages/appkit/src/type-generator/vite-plugin.ts b/packages/appkit/src/type-generator/vite-plugin.ts index 963c9bb78..5f4a0d4b1 100644 --- a/packages/appkit/src/type-generator/vite-plugin.ts +++ b/packages/appkit/src/type-generator/vite-plugin.ts @@ -27,7 +27,6 @@ interface AppKitTypesPluginOptions { * @returns Vite plugin to generate types for AppKit queries. */ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { - let root: string; let outFile: string; let watchFolders: string[]; @@ -74,7 +73,6 @@ export function appKitTypesPlugin(options?: AppKitTypesPluginOptions): Plugin { }, configResolved(config) { - root = config.root; const projectRoot = path.resolve(config.root, ".."); outFile = path.resolve( projectRoot, diff --git a/template/tsconfig.server.json b/template/tsconfig.server.json index 4273c9b27..8e6ff1e5b 100644 --- a/template/tsconfig.server.json +++ b/template/tsconfig.server.json @@ -4,14 +4,8 @@ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.server.tsbuildinfo", "target": "ES2020", "lib": ["ES2020"], - - /* Emit */ "noEmit": true, - "outDir": "./dist", - "rootDir": "./", - "declaration": true, - "declarationMap": true, - "sourceMap": true + "rootDir": "./" }, "include": ["server/**/*", "shared/**/*", "config/**/*"], "exclude": ["node_modules", "dist", "client"] From a2013096107090de43348177c97a98f572531666 Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Thu, 16 Apr 2026 15:57:15 +0200 Subject: [PATCH 06/11] feat: make serving alias optional for single-endpoint registries ServingFactory now adapts based on endpoint count: - Empty registry: alias optional, untyped - Single key: alias optional, fully typed (defaults to the only endpoint) - Multiple keys: alias required for disambiguation Signed-off-by: Pawel Kosiec --- .../api/appkit/TypeAlias.ServingFactory.md | 19 ++++---- packages/appkit/src/plugins/serving/types.ts | 47 ++++++++++++------- 2 files changed, 40 insertions(+), 26 deletions(-) diff --git a/docs/docs/api/appkit/TypeAlias.ServingFactory.md b/docs/docs/api/appkit/TypeAlias.ServingFactory.md index e32ffd648..599080951 100644 --- a/docs/docs/api/appkit/TypeAlias.ServingFactory.md +++ b/docs/docs/api/appkit/TypeAlias.ServingFactory.md @@ -1,19 +1,18 @@ # Type Alias: ServingFactory ```ts -type ServingFactory = keyof ServingEndpointRegistry extends never ? (alias?: string) => ServingEndpointHandle : (alias: K) => ServingEndpointHandle; +type ServingFactory = keyof ServingEndpointRegistry extends never ? (alias?: string) => ServingEndpointHandle : true extends IsUnion ? (alias: K) => ServingEndpointHandle : { + (alias: K): ServingEndpointHandle; + (): ServingEndpointHandle; +}; ``` Factory function returned by `AppKit.serving`. -This is a conditional type that adapts based on whether `ServingEndpointRegistry` -has been populated via module augmentation (generated by `appKitServingTypesPlugin()`): +Adapts based on the `ServingEndpointRegistry` state: -- **Registry empty (default):** `(alias?: string) => ServingEndpointHandle` — - accepts any alias string with untyped request/response. -- **Registry populated:** `(alias: K) => ServingEndpointHandle<...>` — - restricts `alias` to known endpoint keys and infers typed request/response - from the registry entry. +- **Empty (default):** `(alias?: string) => ServingEndpointHandle` — any string, untyped. +- **Single key:** alias optional — `serving()` returns the typed handle for the only endpoint. +- **Multiple keys:** alias required — must specify which endpoint. -Run `appKitServingTypesPlugin()` in your Vite config to generate the registry -augmentation and enable full type safety. +Run `appKitServingTypesPlugin()` in your Vite config to generate the registry. diff --git a/packages/appkit/src/plugins/serving/types.ts b/packages/appkit/src/plugins/serving/types.ts index 2da1f2554..e295a7f65 100644 --- a/packages/appkit/src/plugins/serving/types.ts +++ b/packages/appkit/src/plugins/serving/types.ts @@ -49,26 +49,41 @@ export type ServingEndpointHandle< ) => ServingEndpointMethods; }; +/** True when T is a union of 2+ members; false for a single literal type. */ +type IsUnion = T extends C ? ([C] extends [T] ? false : true) : never; + /** * Factory function returned by `AppKit.serving`. * - * This is a conditional type that adapts based on whether `ServingEndpointRegistry` - * has been populated via module augmentation (generated by `appKitServingTypesPlugin()`): + * Adapts based on the `ServingEndpointRegistry` state: * - * - **Registry empty (default):** `(alias?: string) => ServingEndpointHandle` — - * accepts any alias string with untyped request/response. - * - **Registry populated:** `(alias: K) => ServingEndpointHandle<...>` — - * restricts `alias` to known endpoint keys and infers typed request/response - * from the registry entry. + * - **Empty (default):** `(alias?: string) => ServingEndpointHandle` — any string, untyped. + * - **Single key:** alias optional — `serving()` returns the typed handle for the only endpoint. + * - **Multiple keys:** alias required — must specify which endpoint. * - * Run `appKitServingTypesPlugin()` in your Vite config to generate the registry - * augmentation and enable full type safety. + * Run `appKitServingTypesPlugin()` in your Vite config to generate the registry. */ export type ServingFactory = keyof ServingEndpointRegistry extends never - ? (alias?: string) => ServingEndpointHandle - : ( - alias: K, - ) => ServingEndpointHandle< - ServingEndpointRegistry[K]["request"], - ServingEndpointRegistry[K]["response"] - >; + ? // Empty registry: accept any string, alias optional + (alias?: string) => ServingEndpointHandle + : true extends IsUnion + ? // Multiple keys: alias REQUIRED for disambiguation + ( + alias: K, + ) => ServingEndpointHandle< + ServingEndpointRegistry[K]["request"], + ServingEndpointRegistry[K]["response"] + > + : // Single key: alias optional (runtime defaults to "default") + { + ( + alias: K, + ): ServingEndpointHandle< + ServingEndpointRegistry[K]["request"], + ServingEndpointRegistry[K]["response"] + >; + (): ServingEndpointHandle< + ServingEndpointRegistry[keyof ServingEndpointRegistry]["request"], + ServingEndpointRegistry[keyof ServingEndpointRegistry]["response"] + >; + }; From e41fad577af72f99c569f0c54d235a22059af310 Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Thu, 16 Apr 2026 16:26:19 +0200 Subject: [PATCH 07/11] fix: register named routes in unnamed serving mode for type-generated clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When serving() is called without explicit endpoints (unnamed mode), the type generator still creates a "default" alias. Client hooks then construct /api/serving/default/invoke, but the server only had /invoke registered — causing a 404. Now unnamed mode registers both /invoke and /:alias/invoke (same for stream) so both URL patterns work. Signed-off-by: Pawel Kosiec --- .../appkit/src/plugins/serving/serving.ts | 40 ++++++++++++++----- .../src/plugins/serving/tests/serving.test.ts | 10 +++++ 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/packages/appkit/src/plugins/serving/serving.ts b/packages/appkit/src/plugins/serving/serving.ts index c6c952b61..0cae51d38 100644 --- a/packages/appkit/src/plugins/serving/serving.ts +++ b/packages/appkit/src/plugins/serving/serving.ts @@ -151,24 +151,46 @@ export class ServingPlugin extends Plugin { }, }); } else { + // Unnamed mode: register both /invoke and /:alias/invoke patterns. + // The type generator creates a "default" alias, so clients may use either URL. + const invokeHandler = async ( + req: express.Request, + res: express.Response, + ) => { + req.params.alias ??= "default"; + await this.asUser(req)._handleInvoke(req, res); + }; + const streamHandler = async ( + req: express.Request, + res: express.Response, + ) => { + req.params.alias ??= "default"; + await this.asUser(req)._handleStream(req, res); + }; + this.route(router, { name: "invoke", method: "post", path: "/invoke", - handler: async (req: express.Request, res: express.Response) => { - req.params.alias = "default"; - await this.asUser(req)._handleInvoke(req, res); - }, + handler: invokeHandler, + }); + this.route(router, { + name: "invoke-named", + method: "post", + path: "/:alias/invoke", + handler: invokeHandler, }); - this.route(router, { name: "stream", method: "post", path: "/stream", - handler: async (req: express.Request, res: express.Response) => { - req.params.alias = "default"; - await this.asUser(req)._handleStream(req, res); - }, + handler: streamHandler, + }); + this.route(router, { + name: "stream-named", + method: "post", + path: "/:alias/stream", + handler: streamHandler, }); } } diff --git a/packages/appkit/src/plugins/serving/tests/serving.test.ts b/packages/appkit/src/plugins/serving/tests/serving.test.ts index 6f9750255..2f2be9574 100644 --- a/packages/appkit/src/plugins/serving/tests/serving.test.ts +++ b/packages/appkit/src/plugins/serving/tests/serving.test.ts @@ -90,6 +90,16 @@ describe("Serving Plugin", () => { expect(handlers["POST:/stream"]).toBeDefined(); }); + test("also registers /:alias/invoke and /:alias/stream for type-generated clients", () => { + const plugin = new ServingPlugin({}); + const { router, handlers } = createMockRouter(); + + plugin.injectRoutes(router); + + expect(handlers["POST:/:alias/invoke"]).toBeDefined(); + expect(handlers["POST:/:alias/stream"]).toBeDefined(); + }); + test("exports returns a factory that provides invoke", () => { const plugin = new ServingPlugin({}); const factory = plugin.exports() as any; From b0cb3dc450efeba33cf6cdc0b7bebc545981d675 Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Mon, 20 Apr 2026 11:40:44 +0200 Subject: [PATCH 08/11] feat: auto-migrate project configs for shared/ types output path When type generation runs, automatically patch existing apps' config files to match the new shared/appkit-types/ output location: - tsconfig.client.json: add "shared/appkit-types" to include - tsconfig.server.json: switch from emit mode to noEmit - package.json: update build:server and typecheck scripts Each sub-migration is idempotent (content-based detection, no marker files). Opt-out via "appkit": { "autoMigrate": false } in package.json. Vite plugins now pass projectRoot explicitly to generators. Signed-off-by: Pawel Kosiec --- packages/appkit/src/type-generator/index.ts | 12 +- .../appkit/src/type-generator/migration.ts | 214 +++++++++- .../src/type-generator/serving/generator.ts | 12 +- .../type-generator/tests/migration.test.ts | 365 ++++++++++++++++++ 4 files changed, 595 insertions(+), 8 deletions(-) create mode 100644 packages/appkit/src/type-generator/tests/migration.test.ts diff --git a/packages/appkit/src/type-generator/index.ts b/packages/appkit/src/type-generator/index.ts index 1acd39d8f..579a151d3 100644 --- a/packages/appkit/src/type-generator/index.ts +++ b/packages/appkit/src/type-generator/index.ts @@ -2,7 +2,11 @@ import fs from "node:fs/promises"; import path from "node:path"; import dotenv from "dotenv"; import { createLogger } from "../logging/logger"; -import { removeOldGeneratedTypes } from "./migration"; +import { + migrateProjectConfig, + removeOldGeneratedTypes, + resolveProjectRoot, +} from "./migration"; import { generateQueriesFromDescribe } from "./query-registry"; import { generateServingTypes as generateServingTypesImpl } from "./serving/generator"; import type { QuerySchema } from "./types"; @@ -55,6 +59,7 @@ export async function generateFromEntryPoint(options: { noCache?: boolean; }) { const { outFile, queryFolder, warehouseId, noCache } = options; + const projectRoot = resolveProjectRoot(outFile); logger.debug("Starting type generation..."); @@ -88,8 +93,9 @@ export async function generateFromEntryPoint(options: { await fs.mkdir(path.dirname(outFile), { recursive: true }); await fs.writeFile(outFile, typeDeclarations, "utf-8"); - // One-time migration: remove old generated file from client/src/ to avoid duplicate module augmentation - await removeOldGeneratedTypes(outFile, "analytics.d.ts"); + // One-time migration: remove old generated file and patch project configs + await removeOldGeneratedTypes(projectRoot, "analytics.d.ts"); + await migrateProjectConfig(projectRoot); logger.debug("Type generation complete!"); } diff --git a/packages/appkit/src/type-generator/migration.ts b/packages/appkit/src/type-generator/migration.ts index b9f73b089..cfc841918 100644 --- a/packages/appkit/src/type-generator/migration.ts +++ b/packages/appkit/src/type-generator/migration.ts @@ -1,18 +1,27 @@ import fs from "node:fs/promises"; import path from "node:path"; +import pc from "picocolors"; import { createLogger } from "../logging/logger"; const logger = createLogger("type-generator:migration"); +/** + * Derive project root from an outFile path. + * outFile is always `/shared/appkit-types/` — both the Vite plugins + * and the CLI construct it this way, so going up two levels is safe. + */ +export function resolveProjectRoot(outFile: string): string { + return path.resolve(path.dirname(outFile), "..", ".."); +} + /** * Remove old generated types from client/src/appkit-types/ (pre-shared/ location). * Best-effort: silently ignores missing files. */ export async function removeOldGeneratedTypes( - newOutFile: string, + projectRoot: string, filename: string, ): Promise { - const projectRoot = path.resolve(path.dirname(newOutFile), "..", ".."); const oldFile = path.join( projectRoot, "client", @@ -27,3 +36,204 @@ export async function removeOldGeneratedTypes( // File doesn't exist — nothing to clean up } } + +// ── Project config migration ──────────────────────────────────────────── + +let migrationDone = false; + +/** + * One-time config migration: update tsconfig and package.json for shared/ types output. + * Idempotent — each sub-migration checks current file state and skips if already migrated. + * Opt-out: set `"appkit": { "autoMigrate": false }` in package.json. + */ +export async function migrateProjectConfig(projectRoot: string): Promise { + if (migrationDone) return; + migrationDone = true; + + if (await isAutoMigrateDisabled(projectRoot)) { + logger.debug("Auto-migration disabled via package.json appkit.autoMigrate"); + return; + } + + const results: Array<{ file: string; action: string }> = []; + + results.push(...(await migrateTsconfigClient(projectRoot))); + results.push(...(await migrateTsconfigServer(projectRoot))); + results.push(...(await migratePackageJsonScripts(projectRoot))); + + if (results.length > 0) { + printMigrationSummary(results); + } +} + +/** Exported for testing only. */ +export function _resetMigrationState(): void { + migrationDone = false; +} + +// ── Helpers ───────────────────────────────────────────────────────────── + +async function isAutoMigrateDisabled(projectRoot: string): Promise { + try { + const raw = await fs.readFile( + path.join(projectRoot, "package.json"), + "utf-8", + ); + const parsed = JSON.parse(raw); + return parsed.appkit?.autoMigrate === false; + } catch { + return false; + } +} + +/** Strip JSONC comments (block and line) so JSON.parse can handle tsconfig files. */ +function stripJsonComments(text: string): string { + // Match strings (to skip them) or comments (to remove them). + // Strings must be matched first to avoid stripping comment-like patterns inside string values + // (e.g. "server/**/*" contains /* which looks like a block comment start). + return text.replace(/"(?:[^"\\]|\\.)*"|\/\*[\s\S]*?\*\/|\/\/.*/g, (match) => + match.startsWith('"') ? match : "", + ); +} + +type MigrationResult = Array<{ file: string; action: string }>; + +// ── tsconfig.client.json ──────────────────────────────────────────────── + +async function migrateTsconfigClient( + projectRoot: string, +): Promise { + const results: MigrationResult = []; + const filePath = path.join(projectRoot, "tsconfig.client.json"); + + try { + const raw = await fs.readFile(filePath, "utf-8"); + const parsed = JSON.parse(stripJsonComments(raw)); + + if (!Array.isArray(parsed.include)) return results; + if (parsed.include.includes("shared/appkit-types")) return results; + + parsed.include.push("shared/appkit-types"); + await fs.writeFile( + filePath, + `${JSON.stringify(parsed, null, 2)}\n`, + "utf-8", + ); + results.push({ + file: "tsconfig.client.json", + action: 'added "shared/appkit-types" to include', + }); + } catch { + // File missing or unparseable — skip silently + } + + return results; +} + +// ── tsconfig.server.json ──────────────────────────────────────────────── + +async function migrateTsconfigServer( + projectRoot: string, +): Promise { + const results: MigrationResult = []; + const filePath = path.join(projectRoot, "tsconfig.server.json"); + + try { + const raw = await fs.readFile(filePath, "utf-8"); + const parsed = JSON.parse(stripJsonComments(raw)); + const opts = parsed.compilerOptions; + + if (!opts || !opts.outDir) return results; // already migrated or non-standard + + delete opts.outDir; + delete opts.declaration; + delete opts.declarationMap; + delete opts.sourceMap; + opts.noEmit = true; + + await fs.writeFile( + filePath, + `${JSON.stringify(parsed, null, 2)}\n`, + "utf-8", + ); + results.push({ + file: "tsconfig.server.json", + action: "switched to noEmit mode", + }); + } catch { + // File missing or unparseable — skip silently + } + + return results; +} + +// ── package.json ──────────────────────────────────────────────────────── + +const SCRIPT_MIGRATIONS: Record = { + "build:server": { + old: "tsdown -c tsdown.server.config.ts", + new: "tsc -b tsconfig.server.json && tsdown -c tsdown.server.config.ts", + }, + typecheck: { + old: "tsc -p ./tsconfig.server.json --noEmit && tsc -p ./tsconfig.client.json --noEmit", + new: "tsc -b tsconfig.server.json && tsc -b tsconfig.client.json", + }, +}; + +async function migratePackageJsonScripts( + projectRoot: string, +): Promise { + const results: MigrationResult = []; + const filePath = path.join(projectRoot, "package.json"); + + try { + const raw = await fs.readFile(filePath, "utf-8"); + const parsed = JSON.parse(raw); + const scripts = parsed.scripts; + if (!scripts) return results; + + const updated: string[] = []; + + for (const [name, { old, new: replacement }] of Object.entries( + SCRIPT_MIGRATIONS, + )) { + if (scripts[name] === old) { + scripts[name] = replacement; + updated.push(name); + } + } + + if (updated.length === 0) return results; + + const indent = raw.match(/^\s+/m)?.[0]?.length === 4 ? 4 : 2; + await fs.writeFile( + filePath, + `${JSON.stringify(parsed, null, indent)}\n`, + "utf-8", + ); + results.push({ + file: "package.json", + action: `updated ${updated.join(" and ")} scripts`, + }); + } catch { + // File missing or unparseable — skip silently + } + + return results; +} + +// ── Summary ───────────────────────────────────────────────────────────── + +function printMigrationSummary( + results: Array<{ file: string; action: string }>, +) { + const separator = pc.dim("─".repeat(50)); + console.log(""); + console.log(` ${pc.bold("Typegen Migration")}`); + console.log(` ${separator}`); + for (const { file, action } of results) { + console.log(` ${pc.green("✓")} ${file.padEnd(24)} ${pc.dim(action)}`); + } + console.log(` ${separator}`); + console.log(""); +} diff --git a/packages/appkit/src/type-generator/serving/generator.ts b/packages/appkit/src/type-generator/serving/generator.ts index e6ff69920..8e1df5b66 100644 --- a/packages/appkit/src/type-generator/serving/generator.ts +++ b/packages/appkit/src/type-generator/serving/generator.ts @@ -4,7 +4,11 @@ import { WorkspaceClient } from "@databricks/sdk-experimental"; import pc from "picocolors"; import { createLogger } from "../../logging/logger"; import type { EndpointConfig } from "../../plugins/serving/types"; -import { removeOldGeneratedTypes } from "../migration"; +import { + migrateProjectConfig, + removeOldGeneratedTypes, + resolveProjectRoot, +} from "../migration"; import { CACHE_VERSION, hashSchema, @@ -48,6 +52,7 @@ export async function generateServingTypes( options: GenerateServingTypesOptions, ): Promise { const { outFile, noCache } = options; + const projectRoot = resolveProjectRoot(outFile); // Resolve endpoints: explicit > AST extraction from server file > env var fallback const endpoints = @@ -89,8 +94,9 @@ export async function generateServingTypes( await fs.mkdir(path.dirname(outFile), { recursive: true }); await fs.writeFile(outFile, output, "utf-8"); - // One-time migration: remove old generated file from client/src/ to avoid duplicate module augmentation - await removeOldGeneratedTypes(outFile, "serving.d.ts"); + // One-time migration: remove old generated file and patch project configs + await removeOldGeneratedTypes(projectRoot, "serving.d.ts"); + await migrateProjectConfig(projectRoot); if (registryEntries.length === 0) { logger.debug( diff --git a/packages/appkit/src/type-generator/tests/migration.test.ts b/packages/appkit/src/type-generator/tests/migration.test.ts new file mode 100644 index 000000000..f7a1b701f --- /dev/null +++ b/packages/appkit/src/type-generator/tests/migration.test.ts @@ -0,0 +1,365 @@ +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { _resetMigrationState, migrateProjectConfig } from "../migration"; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "appkit-migration-test-")); + _resetMigrationState(); +}); + +afterEach(async () => { + await fsp.rm(tmpDir, { recursive: true, force: true }); +}); + +function writeFile(name: string, content: string) { + return fsp.writeFile(path.join(tmpDir, name), content, "utf-8"); +} + +function readFile(name: string) { + return fsp.readFile(path.join(tmpDir, name), "utf-8"); +} + +describe("migrateProjectConfig", () => { + // ── tsconfig.client.json ──────────────────────────────────────────── + + describe("tsconfig.client.json", () => { + test("adds shared/appkit-types to include", async () => { + await writeFile( + "tsconfig.client.json", + JSON.stringify({ include: ["client/src"] }, null, 2), + ); + + await migrateProjectConfig(tmpDir); + + const result = JSON.parse(await readFile("tsconfig.client.json")); + expect(result.include).toEqual(["client/src", "shared/appkit-types"]); + }); + + test("no-op if shared/appkit-types already present", async () => { + const original = JSON.stringify( + { include: ["client/src", "shared/appkit-types"] }, + null, + 2, + ); + await writeFile("tsconfig.client.json", original); + + await migrateProjectConfig(tmpDir); + + const result = await readFile("tsconfig.client.json"); + expect(result).toBe(original); + }); + + test("handles JSONC block comments", async () => { + await writeFile( + "tsconfig.client.json", + `{ + /* Bundler mode */ + "compilerOptions": {}, + "include": ["client/src"] +}`, + ); + + await migrateProjectConfig(tmpDir); + + const result = JSON.parse(await readFile("tsconfig.client.json")); + expect(result.include).toEqual(["client/src", "shared/appkit-types"]); + }); + + test("handles JSONC line comments", async () => { + _resetMigrationState(); + await writeFile( + "tsconfig.client.json", + `{ + "compilerOptions": { + "target": "ES2022" // running on modern node + }, + "include": ["client/src"] +}`, + ); + + await migrateProjectConfig(tmpDir); + + const result = JSON.parse(await readFile("tsconfig.client.json")); + expect(result.include).toEqual(["client/src", "shared/appkit-types"]); + }); + + test("skips if include is not an array", async () => { + const original = JSON.stringify({ compilerOptions: {} }, null, 2); + await writeFile("tsconfig.client.json", original); + + await migrateProjectConfig(tmpDir); + + const result = await readFile("tsconfig.client.json"); + expect(result).toBe(original); + }); + }); + + // ── tsconfig.server.json ──────────────────────────────────────────── + + describe("tsconfig.server.json", () => { + test("removes emit config and adds noEmit", async () => { + await writeFile( + "tsconfig.server.json", + JSON.stringify( + { + compilerOptions: { + tsBuildInfoFile: + "./node_modules/.tmp/tsconfig.server.tsbuildinfo", + target: "ES2020", + lib: ["ES2020"], + outDir: "./dist", + rootDir: "./", + declaration: true, + declarationMap: true, + sourceMap: true, + }, + }, + null, + 2, + ), + ); + + await migrateProjectConfig(tmpDir); + + const result = JSON.parse(await readFile("tsconfig.server.json")); + expect(result.compilerOptions.outDir).toBeUndefined(); + expect(result.compilerOptions.declaration).toBeUndefined(); + expect(result.compilerOptions.declarationMap).toBeUndefined(); + expect(result.compilerOptions.sourceMap).toBeUndefined(); + expect(result.compilerOptions.noEmit).toBe(true); + expect(result.compilerOptions.rootDir).toBe("./"); + expect(result.compilerOptions.target).toBe("ES2020"); + }); + + test("no-op if already using noEmit (no outDir)", async () => { + const original = JSON.stringify( + { compilerOptions: { noEmit: true, rootDir: "./" } }, + null, + 2, + ); + await writeFile("tsconfig.server.json", original); + + await migrateProjectConfig(tmpDir); + + const result = await readFile("tsconfig.server.json"); + expect(result).toBe(original); + }); + + test("handles JSONC comments", async () => { + await writeFile( + "tsconfig.server.json", + `{ + "compilerOptions": { + "target": "ES2020", + /* Emit */ + "outDir": "./dist", + "declaration": true + } +}`, + ); + + await migrateProjectConfig(tmpDir); + + const result = JSON.parse(await readFile("tsconfig.server.json")); + expect(result.compilerOptions.outDir).toBeUndefined(); + expect(result.compilerOptions.noEmit).toBe(true); + }); + + test("preserves glob patterns in include paths", async () => { + _resetMigrationState(); + await writeFile( + "tsconfig.server.json", + `{ + "extends": "./tsconfig.shared.json", + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020"], + + /* Emit */ + "outDir": "./dist", + "rootDir": "./", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["server/**/*", "shared/**/*", "config/**/*"], + "exclude": ["node_modules", "dist", "client"] +}`, + ); + + await migrateProjectConfig(tmpDir); + + const result = JSON.parse(await readFile("tsconfig.server.json")); + expect(result.include).toEqual([ + "server/**/*", + "shared/**/*", + "config/**/*", + ]); + expect(result.compilerOptions.outDir).toBeUndefined(); + expect(result.compilerOptions.noEmit).toBe(true); + }); + }); + + // ── package.json ──────────────────────────────────────────────────── + + describe("package.json", () => { + test("replaces old build:server and typecheck scripts", async () => { + await writeFile( + "package.json", + JSON.stringify( + { + name: "test-app", + scripts: { + "build:server": "tsdown -c tsdown.server.config.ts", + typecheck: + "tsc -p ./tsconfig.server.json --noEmit && tsc -p ./tsconfig.client.json --noEmit", + }, + }, + null, + 2, + ), + ); + + await migrateProjectConfig(tmpDir); + + const result = JSON.parse(await readFile("package.json")); + expect(result.scripts["build:server"]).toBe( + "tsc -b tsconfig.server.json && tsdown -c tsdown.server.config.ts", + ); + expect(result.scripts.typecheck).toBe( + "tsc -b tsconfig.server.json && tsc -b tsconfig.client.json", + ); + }); + + test("no-op if scripts already match new values", async () => { + const original = JSON.stringify( + { + name: "test-app", + scripts: { + "build:server": + "tsc -b tsconfig.server.json && tsdown -c tsdown.server.config.ts", + typecheck: + "tsc -b tsconfig.server.json && tsc -b tsconfig.client.json", + }, + }, + null, + 2, + ); + await writeFile("package.json", original); + + await migrateProjectConfig(tmpDir); + + const result = await readFile("package.json"); + expect(result).toBe(original); + }); + + test("skips custom scripts that don't match old values", async () => { + const original = JSON.stringify( + { + name: "test-app", + scripts: { + "build:server": "my-custom-build-script", + typecheck: "my-custom-typecheck", + }, + }, + null, + 2, + ); + await writeFile("package.json", original); + + await migrateProjectConfig(tmpDir); + + const result = await readFile("package.json"); + expect(result).toBe(original); + }); + + test("preserves 4-space indent", async () => { + await writeFile( + "package.json", + JSON.stringify( + { + name: "test-app", + scripts: { + "build:server": "tsdown -c tsdown.server.config.ts", + }, + }, + null, + 4, + ), + ); + + await migrateProjectConfig(tmpDir); + + const raw = await readFile("package.json"); + // Should use 4-space indent + expect(raw).toContain(' "name"'); + }); + }); + + // ── Edge cases ────────────────────────────────────────────────────── + + test("does not crash when no config files exist", async () => { + await expect(migrateProjectConfig(tmpDir)).resolves.not.toThrow(); + }); + + test("runs only once per session (dedup flag)", async () => { + await writeFile( + "tsconfig.client.json", + JSON.stringify({ include: ["client/src"] }, null, 2), + ); + + await migrateProjectConfig(tmpDir); + + // First call should have migrated + let result = JSON.parse(await readFile("tsconfig.client.json")); + expect(result.include).toContain("shared/appkit-types"); + + // Revert the file manually + await writeFile( + "tsconfig.client.json", + JSON.stringify({ include: ["client/src"] }, null, 2), + ); + + // Second call should be a no-op (flag is set) + await migrateProjectConfig(tmpDir); + result = JSON.parse(await readFile("tsconfig.client.json")); + expect(result.include).not.toContain("shared/appkit-types"); + }); + + test("respects appkit.autoMigrate: false opt-out", async () => { + await writeFile( + "package.json", + JSON.stringify( + { + name: "test-app", + appkit: { autoMigrate: false }, + scripts: { + "build:server": "tsdown -c tsdown.server.config.ts", + }, + }, + null, + 2, + ), + ); + await writeFile( + "tsconfig.client.json", + JSON.stringify({ include: ["client/src"] }, null, 2), + ); + + await migrateProjectConfig(tmpDir); + + // tsconfig should NOT be modified + const tsconfig = JSON.parse(await readFile("tsconfig.client.json")); + expect(tsconfig.include).toEqual(["client/src"]); + + // package.json scripts should NOT be modified + const pkg = JSON.parse(await readFile("package.json")); + expect(pkg.scripts["build:server"]).toBe( + "tsdown -c tsdown.server.config.ts", + ); + }); +}); From 4b699cff70256bc64704a9121b3f4b3e9bc177e9 Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Mon, 20 Apr 2026 17:28:12 +0200 Subject: [PATCH 09/11] fix: improve migration robustness, add type tests, fix stale CLI help - Log warnings on migration sub-function failures instead of silent catch - Track migrated projects per-root (Set) instead of global boolean flag - Validate resolveProjectRoot output by checking for package.json - Add compile-time type tests for IsUnion and ServingFactory conditional - Update CLI help text examples to reference shared/appkit-types/ paths Signed-off-by: Pawel Kosiec --- .../src/plugins/serving/tests/types.test.ts | 86 +++++++++++++++++++ .../appkit/src/type-generator/migration.ts | 46 +++++++--- .../type-generator/tests/migration.test.ts | 38 +++++++- .../shared/src/cli/commands/generate-types.ts | 4 +- 4 files changed, 159 insertions(+), 15 deletions(-) create mode 100644 packages/appkit/src/plugins/serving/tests/types.test.ts diff --git a/packages/appkit/src/plugins/serving/tests/types.test.ts b/packages/appkit/src/plugins/serving/tests/types.test.ts new file mode 100644 index 000000000..e99430cc2 --- /dev/null +++ b/packages/appkit/src/plugins/serving/tests/types.test.ts @@ -0,0 +1,86 @@ +import { assertType, describe, expectTypeOf, test } from "vitest"; +import type { ServingEndpointHandle } from "../types"; + +/** + * Compile-time type tests for the serving type system. + * These tests verify that the IsUnion utility type and the 3-way ServingFactory + * conditional produce correct signatures for different registry shapes. + * + * Tests use expectTypeOf (pure type-level, no runtime calls). + */ + +// Mirror IsUnion from types.ts (not exported, so re-declared here for testing) +type IsUnion = T extends C ? ([C] extends [T] ? false : true) : never; + +// ── IsUnion ───────────────────────────────────────────────────────────── + +describe("IsUnion", () => { + test("single literal is not a union", () => { + assertType(false as IsUnion<"a">); + }); + + test("two-member union is detected", () => { + assertType(true as IsUnion<"a" | "b">); + }); + + test("three-member union is detected", () => { + assertType(true as IsUnion<"a" | "b" | "c">); + }); +}); + +// ── ServingFactory-equivalent patterns ────────────────────────────────── +// We can't augment ServingEndpointRegistry differently per test, so we +// test the conditional logic using equivalent local types. + +interface SingleKeyRegistry { + default: { + request: { prompt: string }; + response: { text: string }; + }; +} + +interface MultiKeyRegistry { + llm: { + request: { prompt: string }; + response: { text: string }; + }; + embedder: { + request: { text: string }; + response: number[]; + }; +} + +// Factory type mirroring ServingFactory but parameterised by registry +type TestFactory = keyof R extends never + ? (alias?: string) => ServingEndpointHandle + : true extends IsUnion + ? (alias: K) => ServingEndpointHandle + : { + (alias: K): ServingEndpointHandle; + (): ServingEndpointHandle; + }; + +describe("ServingFactory conditional", () => { + test("empty registry: produces function with optional string param", () => { + type F = TestFactory>; + expectTypeOf().toBeFunction(); + // Alias is optional — should accept (alias?: string) + expectTypeOf().parameter(0).toEqualTypeOf(); + }); + + test("single-key registry: has call signatures including no-arg", () => { + type F = TestFactory; + // Should be callable (it's an object with call signatures) + expectTypeOf().toBeCallableWith("default"); + expectTypeOf().toBeCallableWith(); + }); + + test("multi-key registry: alias is required", () => { + type F = TestFactory; + expectTypeOf().toBeCallableWith("llm"); + expectTypeOf().toBeCallableWith("embedder"); + // No-arg call should NOT be valid — verified via @ts-expect-error + // @ts-expect-error - calling with no args should fail for multi-key + expectTypeOf().toBeCallableWith(); + }); +}); diff --git a/packages/appkit/src/type-generator/migration.ts b/packages/appkit/src/type-generator/migration.ts index cfc841918..11d151490 100644 --- a/packages/appkit/src/type-generator/migration.ts +++ b/packages/appkit/src/type-generator/migration.ts @@ -1,3 +1,4 @@ +import fsSync from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import pc from "picocolors"; @@ -9,9 +10,21 @@ const logger = createLogger("type-generator:migration"); * Derive project root from an outFile path. * outFile is always `/shared/appkit-types/` — both the Vite plugins * and the CLI construct it this way, so going up two levels is safe. + * + * Validates that the resolved root contains a package.json — if not, logs a warning + * so custom outFile paths don't silently operate on the wrong directory. */ export function resolveProjectRoot(outFile: string): string { - return path.resolve(path.dirname(outFile), "..", ".."); + const root = path.resolve(path.dirname(outFile), "..", ".."); + if (!fsSync.existsSync(path.join(root, "package.json"))) { + logger.warn( + "Resolved project root %s has no package.json — migration may target the wrong directory. " + + "Check your outFile path: %s", + root, + outFile, + ); + } + return root; } /** @@ -39,16 +52,18 @@ export async function removeOldGeneratedTypes( // ── Project config migration ──────────────────────────────────────────── -let migrationDone = false; +const migratedProjects = new Set(); /** * One-time config migration: update tsconfig and package.json for shared/ types output. * Idempotent — each sub-migration checks current file state and skips if already migrated. + * Deduplicates per project root so monorepo builds migrate each app independently. * Opt-out: set `"appkit": { "autoMigrate": false }` in package.json. */ export async function migrateProjectConfig(projectRoot: string): Promise { - if (migrationDone) return; - migrationDone = true; + const resolved = path.resolve(projectRoot); + if (migratedProjects.has(resolved)) return; + migratedProjects.add(resolved); if (await isAutoMigrateDisabled(projectRoot)) { logger.debug("Auto-migration disabled via package.json appkit.autoMigrate"); @@ -68,7 +83,7 @@ export async function migrateProjectConfig(projectRoot: string): Promise { /** Exported for testing only. */ export function _resetMigrationState(): void { - migrationDone = false; + migratedProjects.clear(); } // ── Helpers ───────────────────────────────────────────────────────────── @@ -123,8 +138,11 @@ async function migrateTsconfigClient( file: "tsconfig.client.json", action: 'added "shared/appkit-types" to include', }); - } catch { - // File missing or unparseable — skip silently + } catch (err) { + logger.warn( + "Failed to migrate tsconfig.client.json: %s", + (err as Error).message, + ); } return results; @@ -160,8 +178,11 @@ async function migrateTsconfigServer( file: "tsconfig.server.json", action: "switched to noEmit mode", }); - } catch { - // File missing or unparseable — skip silently + } catch (err) { + logger.warn( + "Failed to migrate tsconfig.server.json: %s", + (err as Error).message, + ); } return results; @@ -215,8 +236,11 @@ async function migratePackageJsonScripts( file: "package.json", action: `updated ${updated.join(" and ")} scripts`, }); - } catch { - // File missing or unparseable — skip silently + } catch (err) { + logger.warn( + "Failed to migrate package.json scripts: %s", + (err as Error).message, + ); } return results; diff --git a/packages/appkit/src/type-generator/tests/migration.test.ts b/packages/appkit/src/type-generator/tests/migration.test.ts index f7a1b701f..8b9ee82c2 100644 --- a/packages/appkit/src/type-generator/tests/migration.test.ts +++ b/packages/appkit/src/type-generator/tests/migration.test.ts @@ -306,7 +306,7 @@ describe("migrateProjectConfig", () => { await expect(migrateProjectConfig(tmpDir)).resolves.not.toThrow(); }); - test("runs only once per session (dedup flag)", async () => { + test("runs only once per project root (dedup)", async () => { await writeFile( "tsconfig.client.json", JSON.stringify({ include: ["client/src"] }, null, 2), @@ -324,12 +324,46 @@ describe("migrateProjectConfig", () => { JSON.stringify({ include: ["client/src"] }, null, 2), ); - // Second call should be a no-op (flag is set) + // Second call with same projectRoot should be a no-op (already migrated) await migrateProjectConfig(tmpDir); result = JSON.parse(await readFile("tsconfig.client.json")); expect(result.include).not.toContain("shared/appkit-types"); }); + test("migrates different project roots independently", async () => { + const tmpDir2 = await fsp.mkdtemp( + path.join(os.tmpdir(), "appkit-migration-test2-"), + ); + + try { + // Set up both projects + await writeFile( + "tsconfig.client.json", + JSON.stringify({ include: ["client/src"] }, null, 2), + ); + await fsp.writeFile( + path.join(tmpDir2, "tsconfig.client.json"), + JSON.stringify({ include: ["client/src"] }, null, 2), + "utf-8", + ); + + // Migrate first project + await migrateProjectConfig(tmpDir); + + // Second project should still migrate independently + await migrateProjectConfig(tmpDir2); + + const result1 = JSON.parse(await readFile("tsconfig.client.json")); + const result2 = JSON.parse( + await fsp.readFile(path.join(tmpDir2, "tsconfig.client.json"), "utf-8"), + ); + expect(result1.include).toContain("shared/appkit-types"); + expect(result2.include).toContain("shared/appkit-types"); + } finally { + await fsp.rm(tmpDir2, { recursive: true, force: true }); + } + }); + test("respects appkit.autoMigrate: false opt-out", async () => { await writeFile( "package.json", diff --git a/packages/shared/src/cli/commands/generate-types.ts b/packages/shared/src/cli/commands/generate-types.ts index 6fab73fc3..1be7c7e2e 100644 --- a/packages/shared/src/cli/commands/generate-types.ts +++ b/packages/shared/src/cli/commands/generate-types.ts @@ -82,8 +82,8 @@ export const generateTypesCommand = new Command("generate-types") ` Examples: $ appkit generate-types - $ appkit generate-types . client/src/types.d.ts - $ appkit generate-types . client/src/types.d.ts my-warehouse-id + $ appkit generate-types . shared/appkit-types/analytics.d.ts + $ appkit generate-types . shared/appkit-types/analytics.d.ts my-warehouse-id $ appkit generate-types --no-cache`, ) .action(runGenerateTypes); From f28fc5b5b52f7d66b247b8827a977ffaa11b7ab3 Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Mon, 20 Apr 2026 19:05:27 +0200 Subject: [PATCH 10/11] fix: clean up pre-release type files (appKitTypes.d.ts, appKitServingTypes.d.ts) Target the actually shipped filenames for old type cleanup instead of the intermediate appkit-types/ directory that was never released. Fix outdated JSDoc comments referencing old paths. Signed-off-by: Pawel Kosiec --- packages/appkit-ui/src/react/hooks/types.ts | 2 +- packages/appkit/src/type-generator/index.ts | 2 +- packages/appkit/src/type-generator/migration.ts | 10 ++-------- .../appkit/src/type-generator/serving/generator.ts | 2 +- .../appkit/src/type-generator/serving/vite-plugin.ts | 2 +- 5 files changed, 6 insertions(+), 12 deletions(-) diff --git a/packages/appkit-ui/src/react/hooks/types.ts b/packages/appkit-ui/src/react/hooks/types.ts index bd5a7dc2e..03e943e2a 100644 --- a/packages/appkit-ui/src/react/hooks/types.ts +++ b/packages/appkit-ui/src/react/hooks/types.ts @@ -59,7 +59,7 @@ export interface UseAnalyticsQueryResult { * * @example * ```typescript - * // config/appKitTypes.d.ts + * // shared/appkit-types/analytics.d.ts * declare module "@databricks/appkit-ui/react" { * interface QueryRegistry { * apps_list: { diff --git a/packages/appkit/src/type-generator/index.ts b/packages/appkit/src/type-generator/index.ts index 579a151d3..c9a528fe7 100644 --- a/packages/appkit/src/type-generator/index.ts +++ b/packages/appkit/src/type-generator/index.ts @@ -94,7 +94,7 @@ export async function generateFromEntryPoint(options: { await fs.writeFile(outFile, typeDeclarations, "utf-8"); // One-time migration: remove old generated file and patch project configs - await removeOldGeneratedTypes(projectRoot, "analytics.d.ts"); + await removeOldGeneratedTypes(projectRoot, "appKitTypes.d.ts"); await migrateProjectConfig(projectRoot); logger.debug("Type generation complete!"); diff --git a/packages/appkit/src/type-generator/migration.ts b/packages/appkit/src/type-generator/migration.ts index 11d151490..f44e722a6 100644 --- a/packages/appkit/src/type-generator/migration.ts +++ b/packages/appkit/src/type-generator/migration.ts @@ -28,20 +28,14 @@ export function resolveProjectRoot(outFile: string): string { } /** - * Remove old generated types from client/src/appkit-types/ (pre-shared/ location). + * Remove old generated types from client/src/ (pre-shared/ location). * Best-effort: silently ignores missing files. */ export async function removeOldGeneratedTypes( projectRoot: string, filename: string, ): Promise { - const oldFile = path.join( - projectRoot, - "client", - "src", - "appkit-types", - filename, - ); + const oldFile = path.join(projectRoot, "client", "src", filename); try { await fs.unlink(oldFile); logger.debug("Removed old types at %s", oldFile); diff --git a/packages/appkit/src/type-generator/serving/generator.ts b/packages/appkit/src/type-generator/serving/generator.ts index 8e1df5b66..b377c6597 100644 --- a/packages/appkit/src/type-generator/serving/generator.ts +++ b/packages/appkit/src/type-generator/serving/generator.ts @@ -95,7 +95,7 @@ export async function generateServingTypes( await fs.writeFile(outFile, output, "utf-8"); // One-time migration: remove old generated file and patch project configs - await removeOldGeneratedTypes(projectRoot, "serving.d.ts"); + await removeOldGeneratedTypes(projectRoot, "appKitServingTypes.d.ts"); await migrateProjectConfig(projectRoot); if (registryEntries.length === 0) { diff --git a/packages/appkit/src/type-generator/serving/vite-plugin.ts b/packages/appkit/src/type-generator/serving/vite-plugin.ts index 7542a4598..5c2a3b172 100644 --- a/packages/appkit/src/type-generator/serving/vite-plugin.ts +++ b/packages/appkit/src/type-generator/serving/vite-plugin.ts @@ -11,7 +11,7 @@ import { const logger = createLogger("type-generator:serving:vite-plugin"); interface AppKitServingTypesPluginOptions { - /** Path to the output .d.ts file (relative to client root). Default: "src/appKitServingTypes.d.ts" */ + /** Path to the output .d.ts file (relative to project root). */ outFile?: string; /** Endpoint config override. If omitted, auto-discovers from the server file or falls back to DATABRICKS_SERVING_ENDPOINT_NAME env var. */ endpoints?: Record; From 3c4f2189aff5804a988618b2d59b169f7d6ff6ad Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Mon, 20 Apr 2026 19:17:54 +0200 Subject: [PATCH 11/11] docs: update stale type file path references to shared/appkit-types/ Signed-off-by: Pawel Kosiec --- .../client/src/routes/type-safety.route.tsx | 4 ++-- docs/docs/api/appkit/TypeAlias.ServingFactory.md | 2 +- docs/docs/development/type-generation.md | 9 ++++----- docs/docs/plugins/analytics.md | 2 +- packages/appkit/src/plugins/serving/types.ts | 2 +- 5 files changed, 9 insertions(+), 10 deletions(-) diff --git a/apps/dev-playground/client/src/routes/type-safety.route.tsx b/apps/dev-playground/client/src/routes/type-safety.route.tsx index 07084c534..6962d5d51 100644 --- a/apps/dev-playground/client/src/routes/type-safety.route.tsx +++ b/apps/dev-playground/client/src/routes/type-safety.route.tsx @@ -454,7 +454,7 @@ function TypeSafetyRoute() { 2. Generated Types - Vite plugin or npx command generates appKitTypes.d.ts at build + Vite plugin or npx command generates analytics types at build time @@ -507,7 +507,7 @@ export default defineConfig({ plugins: [ appKitTypesPlugin(), // Optional: appKitTypesPlugin({ - // outputFile: 'src/appKitTypes.d.ts', + // outFile: 'shared/appkit-types/analytics.d.ts', // watchFolders: ['../config/queries'], // }), ], diff --git a/docs/docs/api/appkit/TypeAlias.ServingFactory.md b/docs/docs/api/appkit/TypeAlias.ServingFactory.md index 599080951..bb2b04d28 100644 --- a/docs/docs/api/appkit/TypeAlias.ServingFactory.md +++ b/docs/docs/api/appkit/TypeAlias.ServingFactory.md @@ -15,4 +15,4 @@ Adapts based on the `ServingEndpointRegistry` state: - **Single key:** alias optional — `serving()` returns the typed handle for the only endpoint. - **Multiple keys:** alias required — must specify which endpoint. -Run `appKitServingTypesPlugin()` in your Vite config to generate the registry. +Run `npx appkit generate-types` or start the dev server to generate the registry. diff --git a/docs/docs/development/type-generation.md b/docs/docs/development/type-generation.md index fd71808ae..c433bb5a9 100644 --- a/docs/docs/development/type-generation.md +++ b/docs/docs/development/type-generation.md @@ -10,7 +10,7 @@ AppKit can automatically generate TypeScript types for your SQL queries, providi Generate type-safe TypeScript declarations for query keys, parameters, and result rows. -All generated files live in `client/src/appkit-types/`, one per plugin (e.g. `analytics.d.ts`). They use [`declare module`](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation) to augment existing interfaces, so the types apply globally — you never need to import them. TypeScript auto-discovers them through `"include": ["src"]` in your tsconfig. +All generated files live in `shared/appkit-types/`, one per plugin (e.g. `analytics.d.ts`). They use [`declare module`](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation) to augment existing interfaces, so the types apply globally — you never need to import them. TypeScript auto-discovers them through `"include": ["shared/appkit-types"]` in your tsconfig. ## Vite plugin: `appKitTypesPlugin` @@ -18,7 +18,7 @@ The recommended approach is to use the Vite plugin, which watches your SQL files ### Configuration -- `outFile?: string` - Output file path (default: `src/appkit-types/analytics.d.ts`) +- `outFile?: string` - Output file path (default: `shared/appkit-types/analytics.d.ts`) - `watchFolders?: string[]` - Folders to watch for SQL files (default: `["../config/queries"]`) ### Example @@ -33,7 +33,6 @@ export default defineConfig({ plugins: [ react(), appKitTypesPlugin({ - outFile: "src/appkit-types/analytics.d.ts", watchFolders: ["../config/queries"], }), ], @@ -58,13 +57,13 @@ npx @databricks/appkit generate-types [rootDir] [outFile] [warehouseId] - Generate types using warehouse ID from environment ```bash - npx @databricks/appkit generate-types . client/src/appkit-types/analytics.d.ts + npx @databricks/appkit generate-types . shared/appkit-types/analytics.d.ts ``` - Generate types using warehouse ID explicitly ```bash - npx @databricks/appkit generate-types . client/src/appkit-types/analytics.d.ts abc123... + npx @databricks/appkit generate-types . shared/appkit-types/analytics.d.ts abc123... ``` - Force regeneration (skip cache) diff --git a/docs/docs/plugins/analytics.md b/docs/docs/plugins/analytics.md index 187bf4349..22204f529 100644 --- a/docs/docs/plugins/analytics.md +++ b/docs/docs/plugins/analytics.md @@ -136,7 +136,7 @@ function SpendTable() { Augment the `QueryRegistry` interface to get full type inference on parameters and results: ```ts -// client/src/appkit-types/analytics.d.ts +// shared/appkit-types/analytics.d.ts declare module "@databricks/appkit-ui/react" { interface QueryRegistry { spend_summary: { diff --git a/packages/appkit/src/plugins/serving/types.ts b/packages/appkit/src/plugins/serving/types.ts index e295a7f65..9ba4616fd 100644 --- a/packages/appkit/src/plugins/serving/types.ts +++ b/packages/appkit/src/plugins/serving/types.ts @@ -61,7 +61,7 @@ type IsUnion = T extends C ? ([C] extends [T] ? false : true) : never; * - **Single key:** alias optional — `serving()` returns the typed handle for the only endpoint. * - **Multiple keys:** alias required — must specify which endpoint. * - * Run `appKitServingTypesPlugin()` in your Vite config to generate the registry. + * Run `npx appkit generate-types` or start the dev server to generate the registry. */ export type ServingFactory = keyof ServingEndpointRegistry extends never ? // Empty registry: accept any string, alias optional