Skip to content

Commit b13a39d

Browse files
authored
Merge branch 'main' into claude/add-index-ts-to-mcp-key-files
2 parents 920206c + db65ef7 commit b13a39d

File tree

27 files changed

+240
-46
lines changed

27 files changed

+240
-46
lines changed

apps/framework-docs-v2/content/moosestack/reference/query-layer.mdx

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,19 +29,19 @@ const visitsModel = defineQueryModel({
2929
table: VisitsTable,
3030

3131
dimensions: {
32-
status: { column: "status" },
32+
status: { column: "status", description: "Visit status category" },
3333
...timeDimensions(VisitsTable.columns.timestamp),
3434
},
3535

3636
metrics: {
37-
totalVisits: { agg: count(), as: "total_visits" },
38-
totalRevenue: { agg: sum(VisitsTable.columns.amount), as: "total_revenue" },
37+
totalVisits: { agg: count(), as: "total_visits", description: "Total number of visits" },
38+
totalRevenue: { agg: sum(VisitsTable.columns.amount), as: "total_revenue", description: "Sum of visit revenue" },
3939
},
4040

4141
filters: {
42-
status: { column: "status", operators: ["eq", "in"] as const },
43-
amount: { column: "amount", operators: ["gte", "lte"] as const },
44-
totalRevenue: { metric: "totalRevenue", operators: ["gte"] as const },
42+
status: { column: "status", operators: ["eq", "in"] as const, description: "Filter by visit status" },
43+
amount: { column: "amount", operators: ["gte", "lte"] as const, description: "Filter by amount range" },
44+
totalRevenue: { metric: "totalRevenue", operators: ["gte"] as const, description: "Filter by minimum total revenue" },
4545
},
4646

4747
columns: {
@@ -86,13 +86,40 @@ const visitsModel = defineQueryModel({
8686
| `column` | `keyof TModel` | Column name for simple fields |
8787
| `expression` | `Sql` | Custom SQL expression (alternative to `column`) |
8888
| `as` | `string` | Output alias |
89+
| `description` | `string` | Human-readable description (used for MCP tool generation and documentation) |
8990

9091
### MetricDef
9192

9293
| Property | Type | Description |
9394
|----------|------|-------------|
9495
| `agg` | `Sql` | Aggregation expression (e.g., `count()`, `sum(col)`) |
9596
| `as` | `string` | Output column alias |
97+
| `description` | `string` | Human-readable description (used for MCP tool generation and documentation) |
98+
99+
#### Conditional metrics
100+
101+
To define a metric that only aggregates rows matching a condition, use ClickHouse's
102+
[`-If` aggregate combinators](https://clickhouse.com/docs/en/sql-reference/aggregate-functions/combinators#-if)
103+
directly in the `agg` expression. This makes the metric self-contained — callers don't
104+
need to remember to add a filter.
105+
106+
```typescript
107+
metrics: {
108+
// Total revenue — only from completed events
109+
revenue: {
110+
agg: sql.fragment`sumIf(${Events.columns.amount}, ${Events.columns.status} = 'completed')`,
111+
description: "Total revenue from completed events",
112+
},
113+
// Completion rate
114+
completionRate: {
115+
agg: sql.fragment`countIf(${Events.columns.status} = 'completed') / count(*)`,
116+
description: "Fraction of events that completed",
117+
},
118+
}
119+
```
120+
121+
Common `-If` combinators: `sumIf`, `countIf`, `avgIf`, `minIf`, `maxIf`, `uniqIf`, `uniqExactIf`.
122+
Every ClickHouse aggregate function supports the `-If` suffix.
96123

97124
### ColumnDef
98125

@@ -121,6 +148,7 @@ const visitsModel = defineQueryModel({
121148
| `operators` | `readonly FilterOperator[]` | Allowed operators |
122149
| `transform` | `(value) => SqlValue` | Optional value transformer |
123150
| `inputType` | `FilterInputTypeHint` | UI hint: `"text"`, `"number"`, `"date"`, `"select"`, `"multiselect"` |
151+
| `description` | `string` | Human-readable description (used for MCP tool generation and documentation) |
124152
| `required` | `true` | Make the `eq` parameter required for MCP |
125153

126154
### Filter Operators
@@ -148,8 +176,8 @@ The object returned by `defineQueryModel()`.
148176
| `defaults` | `object` | Default query behavior |
149177
| `filters` | `TFilters` | Filter definitions |
150178
| `sortable` | `readonly TSortable[]` | Sortable field names |
151-
| `dimensionNames` | `readonly string[]` | Dimension key names |
152-
| `metricNames` | `readonly string[]` | Metric key names |
179+
| `dimensions` | `TDimensions \| undefined` | Dimension definitions record (derive names via `Object.keys(model.dimensions)`) |
180+
| `metrics` | `TMetrics \| undefined` | Metric definitions record (derive names via `Object.keys(model.metrics)`) |
153181
| `columnNames` | `readonly string[]` | Column key names |
154182

155183
### Type Inference Helpers

examples/nextjs-moose/components/report-builder/prepare-model.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,8 +186,6 @@ export function prepareModel<
186186
inputType?: FilterInputType;
187187
}
188188
>;
189-
readonly dimensionNames?: readonly string[];
190-
readonly metricNames?: readonly string[];
191189
},
192190
>(queryModel: TModel, options: PrepareModelOptions = {}): ReportModel {
193191
const {

examples/nextjs-moose/moose/src/query-examples/model.ts

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,39 +8,77 @@ export const eventsModel = defineQueryModel({
88
// Dimensions: columns for grouping and filtering
99
// Key names are used as SQL aliases automatically (no `as` needed)
1010
dimensions: {
11-
status: { column: "status" },
11+
status: {
12+
column: "status",
13+
description: "Event processing status",
14+
},
1215
hour: {
1316
expression: sql.fragment`toStartOfHour(${Events.columns.event_time})`,
1417
as: "time",
18+
description: "Event time truncated to the hour",
1519
},
1620
day: {
1721
expression: sql.fragment`toDate(${Events.columns.event_time})`,
1822
as: "time",
23+
description: "Event date (day granularity)",
1924
},
2025
month: {
2126
expression: sql.fragment`toStartOfMonth(${Events.columns.event_time})`,
2227
as: "time",
28+
description: "Event date truncated to the start of the month",
2329
},
2430
},
2531

2632
// Metrics: aggregates computed over dimensions
2733
// Key names are used as SQL aliases automatically (no `as` needed)
2834
metrics: {
29-
totalEvents: { agg: sql.fragment`count(*)` },
30-
totalAmount: { agg: sql.fragment`sum(${Events.columns.amount})` },
31-
avgAmount: { agg: sql.fragment`avg(${Events.columns.amount})` },
32-
minAmount: { agg: sql.fragment`min(${Events.columns.amount})` },
33-
maxAmount: { agg: sql.fragment`max(${Events.columns.amount})` },
35+
totalEvents: {
36+
agg: sql.fragment`count(*)`,
37+
description: "Total number of events",
38+
},
39+
totalAmount: {
40+
agg: sql.fragment`sum(${Events.columns.amount})`,
41+
description: "Sum of event amounts",
42+
},
43+
avgAmount: {
44+
agg: sql.fragment`avg(${Events.columns.amount})`,
45+
description: "Average event amount",
46+
},
47+
minAmount: {
48+
agg: sql.fragment`min(${Events.columns.amount})`,
49+
description: "Minimum event amount",
50+
},
51+
maxAmount: {
52+
agg: sql.fragment`max(${Events.columns.amount})`,
53+
description: "Maximum event amount",
54+
},
3455
highValueRatio: {
3556
agg: sql.fragment`countIf(${Events.columns.amount} > 100) / count(*)`,
57+
description: "Ratio of events with amount greater than 100",
3658
},
3759
},
3860

3961
filters: {
40-
timestamp: { column: "event_time", operators: ["gte", "lte"] as const },
41-
status: { column: "status", operators: ["eq", "in"] as const },
42-
amount: { column: "amount", operators: ["gte", "lte"] as const },
43-
id: { column: "id", operators: ["eq"] as const },
62+
timestamp: {
63+
column: "event_time",
64+
operators: ["gte", "lte"] as const,
65+
description: "Filter by event timestamp range",
66+
},
67+
status: {
68+
column: "status",
69+
operators: ["eq", "in"] as const,
70+
description: "Filter by event processing status",
71+
},
72+
amount: {
73+
column: "amount",
74+
operators: ["gte", "lte"] as const,
75+
description: "Filter by event amount range",
76+
},
77+
id: {
78+
column: "id",
79+
operators: ["eq"] as const,
80+
description: "Filter by exact event ID",
81+
},
4482
},
4583

4684
// Sortable fields use the key names (camelCase)

examples/nextjs-moose/moose/src/query-layer/query-model.ts

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -205,11 +205,6 @@ export interface QueryModel<
205205
/** Metric definitions */
206206
readonly metrics?: TMetrics;
207207

208-
/** Available dimension names (runtime access) */
209-
readonly dimensionNames: readonly string[];
210-
/** Available metric names (runtime access) */
211-
readonly metricNames: readonly string[];
212-
213208
/**
214209
* Type inference helpers (similar to Drizzle's $inferSelect pattern).
215210
* These are type-only properties that don't exist at runtime.
@@ -419,9 +414,6 @@ export function defineQueryModel<
419414
const dimensionNamesSet = new Set(Object.keys(normalizedDimensions));
420415
const metricNamesSet = new Set(Object.keys(normalizedMetrics));
421416

422-
const dimensionNames = Object.keys(normalizedDimensions) as readonly string[];
423-
const metricNames = Object.keys(normalizedMetrics) as readonly string[];
424-
425417
// Build field SQL expression with alias
426418
const buildFieldExpr = (field: FieldDef, defaultAlias: string): Sql => {
427419
const expr =
@@ -737,8 +729,6 @@ export function defineQueryModel<
737729
sortable,
738730
dimensions: dimensions as TDimensions | undefined,
739731
metrics: metrics as TMetrics | undefined,
740-
dimensionNames,
741-
metricNames,
742732
query: async (request, client: QueryClient) => {
743733
const result = await client.execute(toSql(request));
744734
return result.json();

examples/nextjs-moose/moose/src/query-layer/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ export interface DimensionDef<
8484
expression?: Sql;
8585
/** Output alias for the dimension */
8686
as?: string;
87+
/** Human-readable description (used for MCP tool generation and documentation) */
88+
description?: string;
8789
}
8890

8991
/**
@@ -106,6 +108,8 @@ export interface MetricDef {
106108
agg: Sql;
107109
/** Output alias for the metric (defaults to the key name if not specified) */
108110
as?: string;
111+
/** Human-readable description (used for MCP tool generation and documentation) */
112+
description?: string;
109113
}
110114

111115
// =============================================================================
@@ -154,6 +158,8 @@ export interface ModelFilterDef<
154158
* If not specified, will be inferred from the column's ClickHouse data type.
155159
*/
156160
inputType?: FilterInputTypeHint;
161+
/** Human-readable description (used for MCP tool generation and documentation) */
162+
description?: string;
157163
}
158164

159165
// =============================================================================

packages/ts-moose-lib/src/query-layer/model-tools.ts

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export interface QueryModelFilter {
2121
operators: readonly string[];
2222
inputType?: FilterInputTypeHint;
2323
required?: true;
24+
description?: string;
2425
}
2526

2627
/**
@@ -44,8 +45,8 @@ export interface QueryModelBase {
4445
};
4546
readonly filters: Record<string, QueryModelFilter>;
4647
readonly sortable: readonly string[];
47-
readonly dimensionNames: readonly string[];
48-
readonly metricNames: readonly string[];
48+
readonly dimensions?: Record<string, { description?: string }>;
49+
readonly metrics?: Record<string, { description?: string }>;
4950
readonly columnNames: readonly string[];
5051
toSql(request: Record<string, unknown>): Sql;
5152
}
@@ -66,6 +67,17 @@ function titleFromName(name: string): string {
6667
.replace(/\b\w/g, (c) => c.toUpperCase());
6768
}
6869

70+
function buildEnumDescription(
71+
metadata: Record<string, { description?: string }>,
72+
): string | undefined {
73+
const entries = Object.entries(metadata);
74+
if (entries.length === 0) return undefined;
75+
const lines = entries.map(([name, def]) => {
76+
return def.description ? `- ${name}: ${def.description}` : `- ${name}`;
77+
});
78+
return lines.join("\n");
79+
}
80+
6981
/** Map FilterInputTypeHint to a base Zod type */
7082
function zodBaseType(inputType?: FilterInputTypeHint): z.ZodType {
7183
if (inputType === "number") return z.number();
@@ -161,15 +173,21 @@ export function createModelTool(
161173
const filterParamMap: Record<string, { filterName: string; op: string }> = {};
162174

163175
// --- Dimensions ---
164-
if (model.dimensionNames.length > 0) {
165-
const names = model.dimensionNames as readonly [string, ...string[]];
166-
schema.dimensions = z.array(z.enum(names)).optional();
176+
const dimensionNames = Object.keys(model.dimensions ?? {});
177+
if (dimensionNames.length > 0) {
178+
const names = dimensionNames as [string, ...string[]];
179+
const desc = buildEnumDescription(model.dimensions!);
180+
const dimSchema = z.array(z.enum(names)).optional();
181+
schema.dimensions = desc ? dimSchema.describe(desc) : dimSchema;
167182
}
168183

169184
// --- Metrics ---
170-
if (model.metricNames.length > 0) {
171-
const names = model.metricNames as readonly [string, ...string[]];
172-
schema.metrics = z.array(z.enum(names)).optional();
185+
const metricNames = Object.keys(model.metrics ?? {});
186+
if (metricNames.length > 0) {
187+
const names = metricNames as [string, ...string[]];
188+
const desc = buildEnumDescription(model.metrics!);
189+
const metSchema = z.array(z.enum(names)).optional();
190+
schema.metrics = desc ? metSchema.describe(desc) : metSchema;
173191
}
174192

175193
// --- Columns ---
@@ -203,9 +221,14 @@ export function createModelTool(
203221

204222
// Required if filter is in requiredFilters AND op is eq
205223
if (requiredSet.has(filterName) && op === "eq") {
206-
schema[paramName] = paramType;
224+
schema[paramName] =
225+
filterDef.description ?
226+
paramType.describe(filterDef.description)
227+
: paramType;
207228
} else {
208-
schema[paramName] = paramType.optional();
229+
const opt = paramType.optional();
230+
schema[paramName] =
231+
filterDef.description ? opt.describe(filterDef.description) : opt;
209232
}
210233

211234
filterParamMap[paramName] = { filterName, op };
@@ -227,14 +250,14 @@ export function createModelTool(
227250
const request: Record<string, unknown> = {};
228251

229252
// Dimensions
230-
if (model.dimensionNames.length > 0) {
253+
if (dimensionNames.length > 0) {
231254
request.dimensions =
232255
(params.dimensions as string[] | undefined) ??
233256
mergedDefaults.dimensions;
234257
}
235258

236259
// Metrics
237-
if (model.metricNames.length > 0) {
260+
if (metricNames.length > 0) {
238261
request.metrics =
239262
(params.metrics as string[] | undefined) ?? mergedDefaults.metrics;
240263
}

packages/ts-moose-lib/src/query-layer/query-model.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -258,8 +258,6 @@ export interface QueryModel<
258258
readonly metrics?: TMetrics;
259259
readonly columns?: TColumns;
260260

261-
readonly dimensionNames: readonly string[];
262-
readonly metricNames: readonly string[];
263261
readonly columnNames: readonly string[];
264262

265263
/** Type inference helpers (similar to Drizzle's $inferSelect pattern). */
@@ -859,8 +857,6 @@ export function defineQueryModel<
859857
dimensions,
860858
metrics,
861859
columns: columnDefs,
862-
dimensionNames,
863-
metricNames,
864860
columnNames,
865861
query: async (request, client: QueryClient) => {
866862
const result = await client.execute(toSql(request));

packages/ts-moose-lib/src/query-layer/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export interface DimensionDef<
7474
column?: TKey;
7575
expression?: Sql;
7676
as?: string;
77+
description?: string;
7778
}
7879

7980
/**
@@ -89,6 +90,7 @@ export interface DimensionDef<
8990
export interface MetricDef {
9091
agg: Sql;
9192
as?: string;
93+
description?: string;
9294
}
9395

9496
/**
@@ -167,6 +169,7 @@ export interface ModelFilterDef<
167169
inputType?: FilterInputTypeHint;
168170
/** When true, this filter's `eq` param is required in MCP tool schemas */
169171
required?: true;
172+
description?: string;
170173
}
171174

172175
// --- Type Inference Helpers ---

0 commit comments

Comments
 (0)