Skip to content

Commit ff79cf0

Browse files
authored
feat: add type generation system for end-to-end type safety (#37)
* feat: add strict type for analytics with module augmentation * refactor: analytics plugin to use the new this.route
1 parent 3b5fc0f commit ff79cf0

File tree

19 files changed

+581
-776
lines changed

19 files changed

+581
-776
lines changed

apps/dev-playground/client/src/routes/analytics.route.tsx

Lines changed: 13 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -53,24 +53,9 @@ function AnalyticsRoute() {
5353
data: summaryDataRaw,
5454
loading: summaryLoading,
5555
error: summaryError,
56-
} = useAnalyticsQuery<
57-
Array<{
58-
total: number;
59-
average: number;
60-
forecasted: number;
61-
}>
62-
>("spend_summary", summaryParams);
63-
64-
const { data: appsListData } = useAnalyticsQuery<
65-
Array<{
66-
id: string;
67-
name: string;
68-
creator: string;
69-
tags: string;
70-
totalSpend: number;
71-
createdAt: string;
72-
}>
73-
>("apps_list", {});
56+
} = useAnalyticsQuery("spend_summary", summaryParams);
57+
58+
const { data: appsListData } = useAnalyticsQuery("apps_list", {});
7459

7560
const untaggedAppsParams = useMemo(() => {
7661
return {
@@ -84,14 +69,7 @@ function AnalyticsRoute() {
8469
data: untaggedAppsData,
8570
loading: untaggedAppsLoading,
8671
error: untaggedAppsError,
87-
} = useAnalyticsQuery<
88-
Array<{
89-
app_name: string;
90-
creator: string;
91-
total_cost_usd: number;
92-
avg_period_cost_usd: number;
93-
}>
94-
>("untagged_apps", untaggedAppsParams);
72+
} = useAnalyticsQuery("untagged_apps", untaggedAppsParams);
9573

9674
const metrics = useMemo(() => {
9775
if (!summaryDataRaw || summaryDataRaw.length === 0) {
@@ -104,39 +82,15 @@ function AnalyticsRoute() {
10482
if (!appsListData || appsListData.length === 0) {
10583
return [];
10684
}
107-
return appsListData.map((app, index) => {
108-
let tags: string[] = [];
109-
if (app.tags) {
110-
try {
111-
if (Array.isArray(app.tags)) {
112-
tags = app.tags;
113-
} else if (typeof app.tags === "string") {
114-
const trimmedTags = app.tags.trim();
115-
if (trimmedTags.startsWith("[") || trimmedTags.startsWith("{")) {
116-
tags = JSON.parse(trimmedTags);
117-
} else if (trimmedTags) {
118-
tags = trimmedTags
119-
.split(",")
120-
.map((t: string) => t.trim())
121-
.filter(Boolean);
122-
}
123-
}
124-
} catch (error) {
125-
console.warn("Failed to parse tags for app", app.name, ":", error);
126-
tags = [];
127-
}
128-
}
129-
130-
return {
131-
id: `${app.id}-${app.creator}-${index}`,
132-
name: app.name,
133-
creator: app.creator,
134-
spend: Math.round(app.totalSpend),
135-
status: "unknown" as const,
136-
tags,
137-
lastRun: app.createdAt,
138-
};
139-
});
85+
return appsListData.map((app, index) => ({
86+
id: `${app.id}-${app.creator}-${index}`,
87+
name: app.name,
88+
creator: app.creator,
89+
spend: Math.round(app.totalSpend),
90+
status: "unknown" as const,
91+
tags: app.tags ?? [],
92+
lastRun: app.createdAt,
93+
}));
14094
}, [appsListData]);
14195

14296
const untaggedAppsList = useMemo(() => {

apps/dev-playground/client/tsconfig.app.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,5 @@
2929
"@/*": ["./src/*"]
3030
}
3131
},
32-
"include": ["src"]
32+
"include": ["src", "../config/**/*.d.ts"]
3333
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Auto-generated by AppKit - DO NOT EDIT
2+
// Generated at: 2025-11-27T16:48:55.599Z
3+
4+
import "@databricks/app-kit-ui/react";
5+
6+
declare module "@databricks/app-kit-ui/react" {
7+
interface PluginRegistry {
8+
reconnect: {
9+
"/": {
10+
message: string;
11+
};
12+
"/stream": {
13+
type: string;
14+
count: number;
15+
total: number;
16+
timestamp: string;
17+
content: string;
18+
};
19+
}
20+
analytics: {
21+
"/users/me/query/:query_key": {
22+
chunk_index: number;
23+
row_offset: number;
24+
row_count: number;
25+
data: any[];
26+
};
27+
"/query/:query_key": {
28+
chunk_index: number;
29+
row_offset: number;
30+
row_count: number;
31+
data: any[];
32+
};
33+
}
34+
}
35+
36+
interface QueryRegistry {
37+
apps_list: {
38+
id: string;
39+
name: string;
40+
creator: string;
41+
tags: string[];
42+
totalSpend: number;
43+
createdAt: string;
44+
}[];
45+
spend_summary: {
46+
total: number;
47+
average: number;
48+
forecasted: number;
49+
}[];
50+
untagged_apps: {
51+
app_name: string;
52+
creator: string;
53+
total_cost_usd: number;
54+
avg_period_cost_usd: number;
55+
}[];
56+
spend_data: {
57+
group_key: string;
58+
aggregation_period: string;
59+
cost_usd: number;
60+
}[];
61+
top_contributors: {
62+
app_name: string;
63+
total_cost_usd: number;
64+
}[];
65+
}
66+
67+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { z } from "zod";
2+
3+
export const querySchemas = {
4+
apps_list: z.array(
5+
z.object({
6+
id: z.string(),
7+
name: z.string(),
8+
creator: z.string(),
9+
tags: z.array(z.string()),
10+
totalSpend: z.number(),
11+
createdAt: z.string(),
12+
}),
13+
),
14+
15+
spend_summary: z.array(
16+
z.object({
17+
total: z.number(),
18+
average: z.number(),
19+
forecasted: z.number(),
20+
}),
21+
),
22+
23+
untagged_apps: z.array(
24+
z.object({
25+
app_name: z.string(),
26+
creator: z.string(),
27+
total_cost_usd: z.number(),
28+
avg_period_cost_usd: z.number(),
29+
}),
30+
),
31+
32+
spend_data: z.array(
33+
z.object({
34+
group_key: z.string(),
35+
aggregation_period: z.string(),
36+
cost_usd: z.number(),
37+
}),
38+
),
39+
40+
top_contributors: z.array(
41+
z.object({
42+
app_name: z.string(),
43+
total_cost_usd: z.number(),
44+
}),
45+
),
46+
};

apps/dev-playground/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
"description": "",
2323
"dependencies": {
2424
"@databricks/app-kit": "workspace:*",
25-
"react-syntax-highlighter": "^16.1.0"
25+
"zod": "^4.1.13"
2626
},
2727
"devDependencies": {
2828
"@types/node": "^20.0.0",

apps/dev-playground/server/reconnect-plugin.ts

Lines changed: 54 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,71 @@
11
import { Plugin, toPlugin } from "@databricks/app-kit";
22
import type { IAppRouter, StreamExecutionSettings } from "shared";
3+
import { z } from "zod";
34

45
export class ReconnectPlugin extends Plugin {
56
public name = "reconnect";
67
public envVars = [];
78

89
injectRoutes(router: IAppRouter): void {
9-
router.get("/", (_req, res) => {
10-
res.json({ message: "Reconnected" });
10+
this.route(router, {
11+
method: "get",
12+
path: "/",
13+
schema: z.object({ message: z.string() }),
14+
handler: async (req, res) => {
15+
res.json({ message: "Reconnected" });
16+
},
1117
});
1218

13-
router.get("/stream", async (req, res) => {
14-
const sessionId =
15-
(req.query.sessionId as string) || `session-${Date.now()}`;
16-
const streamId = `reconnect-test-stream-${sessionId}`;
17-
18-
const streamExecutionSettings: StreamExecutionSettings = {
19-
default: {},
20-
user: {},
21-
stream: {
22-
streamId: streamId,
23-
bufferSize: 100,
24-
},
25-
};
26-
27-
await this.executeStream(
28-
res,
29-
async function* (signal) {
30-
for (let i = 1; i <= 5; i++) {
31-
if (signal?.aborted) {
32-
break;
33-
}
19+
this.route(router, {
20+
method: "get",
21+
path: "/stream",
22+
schema: z.object({
23+
type: z.string(),
24+
count: z.number(),
25+
total: z.number(),
26+
timestamp: z.string(),
27+
content: z.string(),
28+
}),
29+
handler: async (req, res) => {
30+
const sessionId =
31+
(req.query.sessionId as string) || `session-${Date.now()}`;
32+
const streamId = `reconnect-test-stream-${sessionId}`;
33+
34+
const streamExecutionSettings: StreamExecutionSettings = {
35+
default: {},
36+
user: {},
37+
stream: {
38+
streamId: streamId,
39+
bufferSize: 100,
40+
},
41+
};
42+
43+
await this.executeStream(
44+
res,
45+
async function* (signal) {
46+
for (let i = 1; i <= 5; i++) {
47+
if (signal?.aborted) {
48+
break;
49+
}
3450

35-
const message = {
36-
type: "message",
37-
count: i,
38-
total: 5,
39-
timestamp: new Date().toISOString(),
40-
content: `Message ${i} of 5`,
41-
};
51+
const message = {
52+
type: "message",
53+
count: i,
54+
total: 5,
55+
timestamp: new Date().toISOString(),
56+
content: `Message ${i} of 5`,
57+
};
4258

43-
yield message;
59+
yield message;
4460

45-
if (i < 5) {
46-
await new Promise((resolve) => setTimeout(resolve, 3000));
61+
if (i < 5) {
62+
await new Promise((resolve) => setTimeout(resolve, 3000));
63+
}
4764
}
48-
}
49-
},
50-
streamExecutionSettings,
51-
);
65+
},
66+
streamExecutionSettings,
67+
);
68+
},
5269
});
5370
}
5471
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,9 @@
1+
export type {
2+
PluginRegistry,
3+
QueryRegistry,
4+
UseAnalyticsQueryOptions,
5+
UseAnalyticsQueryResult,
6+
} from "./types";
17
export { useAnalyticsQuery } from "./use-analytics-query";
28
export { useChartData } from "./use-chart-data";
9+
export { useCustomPlugin } from "./use-custom-plugin";

packages/app-kit-ui/src/react/hooks/types.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,46 @@ export interface UseAnalyticsQueryResult<T> {
1919
/** Error state of the query */
2020
error: string | null;
2121
}
22+
23+
/**
24+
* Query Registry for type-safe analytics queries.
25+
* Extend this interface via module augmentation to get full type inference:
26+
*
27+
* @example
28+
* ```typescript
29+
* // config/appKitTypes.d.ts
30+
* declare module "@databricks/app-kit-ui/react" {
31+
* interface QueryRegistry {
32+
* apps_list: AppListItem[];
33+
* spend_summary: SpendSummary[];
34+
* }
35+
* }
36+
* ```
37+
*/
38+
// biome-ignore lint/suspicious/noEmptyInterface: Required for module augmentation
39+
export interface QueryRegistry {}
40+
41+
/** Resolves to registry keys if defined, otherwise string */
42+
export type QueryKey = keyof QueryRegistry extends never
43+
? string
44+
: keyof QueryRegistry;
45+
46+
// biome-ignore lint/suspicious/noEmptyInterface: Required for module augmentation
47+
export interface PluginRegistry {}
48+
49+
export type PluginName = keyof PluginRegistry extends never
50+
? string
51+
: keyof PluginRegistry;
52+
53+
export type PluginRoutes<P extends PluginName> = P extends keyof PluginRegistry
54+
? keyof PluginRegistry[P]
55+
: string;
56+
57+
export type RouteResponse<
58+
P extends PluginName,
59+
R extends PluginRoutes<P>,
60+
> = P extends keyof PluginRegistry
61+
? R extends keyof PluginRegistry[P]
62+
? PluginRegistry[P][R]
63+
: unknown
64+
: unknown;

0 commit comments

Comments
 (0)