Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/dev-playground/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# Playwright
test-results/
playwright-report/
playwright-report/

# Auto-generated types (endpoint-specific, varies per developer)
shared/appkit-types/serving.d.ts
3 changes: 0 additions & 3 deletions apps/dev-playground/client/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions apps/dev-playground/client/src/routes/type-safety.route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,7 @@ function TypeSafetyRoute() {
<CardHeader>
<CardTitle>2. Generated Types</CardTitle>
<CardDescription>
Vite plugin or npx command generates appKitTypes.d.ts at build
Vite plugin or npx command generates analytics types at build
time
</CardDescription>
</CardHeader>
Expand Down Expand Up @@ -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'],
// }),
],
Expand Down
2 changes: 1 addition & 1 deletion apps/dev-playground/client/tsconfig.app.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,5 @@
"@/*": ["./src/*"]
}
},
"include": ["src"]
"include": ["src", "../shared/appkit-types"]
}
19 changes: 9 additions & 10 deletions docs/docs/api/appkit/TypeAlias.ServingFactory.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
# Type Alias: ServingFactory

```ts
type ServingFactory = keyof ServingEndpointRegistry extends never ? (alias?: string) => ServingEndpointHandle : <K>(alias: K) => ServingEndpointHandle<ServingEndpointRegistry[K]["request"], ServingEndpointRegistry[K]["response"]>;
type ServingFactory = keyof ServingEndpointRegistry extends never ? (alias?: string) => ServingEndpointHandle : true extends IsUnion<keyof ServingEndpointRegistry> ? <K>(alias: K) => ServingEndpointHandle<ServingEndpointRegistry[K]["request"], ServingEndpointRegistry[K]["response"]> : {
<K> (alias: K): ServingEndpointHandle<ServingEndpointRegistry[K]["request"], ServingEndpointRegistry[K]["response"]>;
(): ServingEndpointHandle<never, 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:** `<K>(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 `npx appkit generate-types` or start the dev server to generate the registry.
9 changes: 4 additions & 5 deletions docs/docs/development/type-generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ 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`

The recommended approach is to use the Vite plugin, which watches your SQL files and regenerates types automatically during development.

### 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
Expand All @@ -33,7 +33,6 @@ export default defineConfig({
plugins: [
react(),
appKitTypesPlugin({
outFile: "src/appkit-types/analytics.d.ts",
watchFolders: ["../config/queries"],
}),
],
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/plugins/analytics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
2 changes: 1 addition & 1 deletion packages/appkit-ui/src/react/hooks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export interface UseAnalyticsQueryResult<T> {
*
* @example
* ```typescript
* // config/appKitTypes.d.ts
* // shared/appkit-types/analytics.d.ts
* declare module "@databricks/appkit-ui/react" {
* interface QueryRegistry {
* apps_list: {
Expand Down
40 changes: 31 additions & 9 deletions packages/appkit/src/plugins/serving/serving.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
}
Expand Down
10 changes: 10 additions & 0 deletions packages/appkit/src/plugins/serving/tests/serving.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
86 changes: 86 additions & 0 deletions packages/appkit/src/plugins/serving/tests/types.test.ts
Original file line number Diff line number Diff line change
@@ -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, C = T> = T extends C ? ([C] extends [T] ? false : true) : never;

// ── IsUnion ─────────────────────────────────────────────────────────────

describe("IsUnion", () => {
test("single literal is not a union", () => {
assertType<false>(false as IsUnion<"a">);
});

test("two-member union is detected", () => {
assertType<true>(true as IsUnion<"a" | "b">);
});

test("three-member union is detected", () => {
assertType<true>(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<R> = keyof R extends never
? (alias?: string) => ServingEndpointHandle
: true extends IsUnion<keyof R>
? <K extends keyof R>(alias: K) => ServingEndpointHandle
: {
<K extends keyof R>(alias: K): ServingEndpointHandle;
(): ServingEndpointHandle;
};

describe("ServingFactory conditional", () => {
test("empty registry: produces function with optional string param", () => {
type F = TestFactory<Record<string, never>>;
expectTypeOf<F>().toBeFunction();
// Alias is optional — should accept (alias?: string)
expectTypeOf<F>().parameter(0).toEqualTypeOf<string | undefined>();
});

test("single-key registry: has call signatures including no-arg", () => {
type F = TestFactory<SingleKeyRegistry>;
// Should be callable (it's an object with call signatures)
expectTypeOf<F>().toBeCallableWith("default");
expectTypeOf<F>().toBeCallableWith();
});

test("multi-key registry: alias is required", () => {
type F = TestFactory<MultiKeyRegistry>;
expectTypeOf<F>().toBeCallableWith("llm");
expectTypeOf<F>().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<F>().toBeCallableWith();
});
});
47 changes: 31 additions & 16 deletions packages/appkit/src/plugins/serving/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,26 +49,41 @@ export type ServingEndpointHandle<
) => ServingEndpointMethods<TRequest, TResponse>;
};

/** True when T is a union of 2+ members; false for a single literal type. */
type IsUnion<T, C = T> = 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:** `<K>(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 `npx appkit generate-types` or start the dev server to generate the registry.
*/
export type ServingFactory = keyof ServingEndpointRegistry extends never
? (alias?: string) => ServingEndpointHandle
: <K extends keyof ServingEndpointRegistry>(
alias: K,
) => ServingEndpointHandle<
ServingEndpointRegistry[K]["request"],
ServingEndpointRegistry[K]["response"]
>;
? // Empty registry: accept any string, alias optional
(alias?: string) => ServingEndpointHandle
: true extends IsUnion<keyof ServingEndpointRegistry>
? // Multiple keys: alias REQUIRED for disambiguation
<K extends keyof ServingEndpointRegistry>(
alias: K,
) => ServingEndpointHandle<
ServingEndpointRegistry[K]["request"],
ServingEndpointRegistry[K]["response"]
>
: // Single key: alias optional (runtime defaults to "default")
{
<K extends keyof ServingEndpointRegistry>(
alias: K,
): ServingEndpointHandle<
ServingEndpointRegistry[K]["request"],
ServingEndpointRegistry[K]["response"]
>;
(): ServingEndpointHandle<
ServingEndpointRegistry[keyof ServingEndpointRegistry]["request"],
ServingEndpointRegistry[keyof ServingEndpointRegistry]["response"]
>;
};
10 changes: 10 additions & 0 deletions packages/appkit/src/type-generator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import fs from "node:fs/promises";
import path from "node:path";
import dotenv from "dotenv";
import { createLogger } from "../logging/logger";
import {
migrateProjectConfig,
removeOldGeneratedTypes,
resolveProjectRoot,
} from "./migration";
import { generateQueriesFromDescribe } from "./query-registry";
import { generateServingTypes as generateServingTypesImpl } from "./serving/generator";
import type { QuerySchema } from "./types";
Expand Down Expand Up @@ -54,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...");

Expand Down Expand Up @@ -87,6 +93,10 @@ 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 and patch project configs
await removeOldGeneratedTypes(projectRoot, "appKitTypes.d.ts");
await migrateProjectConfig(projectRoot);

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

Expand Down
Loading
Loading