Skip to content

Commit 7dfea5e

Browse files
author
Ehindero Israel
authored
fix(server): allow omitted optional memory query params (#15969)
This fixes a memory query validation regression that shows up when the runtime resolves Zod 4.4.0 or newer. Zod 4.4.0 tightened missing-key/undefined behavior for object properties, which is called out in the release notes: https://github.com/colinhacks/zod/releases/tag/v4.4.0 Our memory schemas were expressing optional preprocessed query fields as `z.preprocess(fn, inner.optional())`. With newer Zod, omitted params like `orderBy`, `metadata`, `include`, `filter`, and `includeSystemReminders` can then fail with `expected nonoptional, received undefined`, which breaks memory thread/message loading in Studio and the underlying API routes. This changes those fields to the safer equivalent `z.preprocess(fn, inner).optional()` and keeps the preprocess functions passing through `undefined`. That preserves the intended behavior: omitted query params stay optional, but present JSON values still get parsed and validated. Before: ```ts z.preprocess(fn, inner.optional()) ``` After: ```ts z.preprocess(fn, inner).optional() ``` I also added regression coverage for the exact omitted-parameter cases on `listThreadsQuerySchema` and `listMessagesQuerySchema`. I verified this in the `apr-30` demo by linking the local `@mastra/server` package and hitting the same routes that were previously 400ing. After the change, both `/api/memory/threads` and `/api/memory/threads/:threadId/messages` return normal JSON again instead of validation errors. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## ELI5 (Explain Like I'm 5) When you ask an API for information without providing some optional details, the server was incorrectly rejecting your request as invalid (because of a newer version of the validation tool it uses). This PR fixes that by reorganizing how the validation rules work so that optional details are actually treated as optional—if you don't send them, that's perfectly fine. ## Changes Overview This PR fixes a validation regression in the memory query API endpoints that was introduced by Zod 4.4.0+. The issue occurred when optional query parameters were omitted from requests to `/api/memory/threads` and `/api/memory/threads/:threadId/messages`, causing validation errors with "expected nonoptional, received undefined" even though those parameters were intended to be optional. ## Root Cause The memory query schemas were using `z.preprocess(fn, schema.optional())`, which caused Zod 4.4.0+ to reject `undefined` values during the preprocessing step. The stricter validation in newer Zod versions exposed this improper ordering of the optional modifier, preventing the preprocessor from handling undefined values before the optional check. ## Solution Refactored multiple Zod query schemas to move optionality outside the preprocess call, changing from `z.preprocess(fn, schema.optional())` to `z.preprocess(fn, schema).optional()`. This ensures that: - Omitted query parameters (orderBy, metadata, include, filter, includeSystemReminders, resourceId) pass through preprocessing without triggering validation errors, and remain `undefined` in the parsed result - Present JSON values are still properly parsed and validated through the preprocess functions - The intended optional behavior is preserved for backward compatibility ## Files Modified - **packages/server/src/server/schemas/memory.ts**: Refactored preprocess callbacks in `storageOrderBySchema`, `messageOrderBySchema`, `includeSchema`, `filterSchema`, `memoryConfigSchema`, and related metadata/system reminder fields to use the corrected pattern (+122/-103 lines) - **packages/server/src/server/schemas/memory.test.ts**: Added regression tests verifying that `listThreadsQuerySchema` and `listMessagesQuerySchema` successfully parse when optional query parameters are omitted, with those parameters remaining `undefined` in the result (+28 lines) - **.changeset/four-owls-flow.md**: Patch-level version bump for @mastra/server (+5 lines) ## Testing & Verification - Added regression test coverage confirming that omitted optional parameters (`resourceId`, `orderBy`, `metadata`, `include`, `filter`) don't cause validation errors and correctly resolve to `undefined` - Verified locally by linking the @mastra/server package and confirming `/api/memory/threads` and `/api/memory/threads/:threadId/messages` return normal JSON responses instead of 400 validation errors <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 030e805 commit 7dfea5e

3 files changed

Lines changed: 155 additions & 103 deletions

File tree

.changeset/four-owls-flow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@mastra/server': patch
3+
---
4+
5+
Fixed memory query validation when optional JSON query params are omitted with newer Zod versions.

packages/server/src/server/schemas/memory.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,21 @@ import { listMessagesQuerySchema, listThreadsQuerySchema } from './memory';
1414
*/
1515
describe('Memory Schema Query Parsing', () => {
1616
describe('listMessagesQuerySchema', () => {
17+
it('should allow omitted optional query params', () => {
18+
const result = listMessagesQuerySchema.safeParse({
19+
page: 0,
20+
perPage: 20,
21+
});
22+
23+
expect(result.success).toBe(true);
24+
if (result.success) {
25+
expect(result.data.orderBy).toBeUndefined();
26+
expect(result.data.include).toBeUndefined();
27+
expect(result.data.filter).toBeUndefined();
28+
expect(result.data.includeSystemReminders).toBeUndefined();
29+
}
30+
});
31+
1732
describe('orderBy parameter parsing', () => {
1833
it('should parse orderBy when passed as an object', () => {
1934
const result = listMessagesQuerySchema.safeParse({
@@ -251,6 +266,19 @@ describe('Memory Schema Query Parsing', () => {
251266
});
252267

253268
describe('listThreadsQuerySchema', () => {
269+
it('should allow omitted optional query params', () => {
270+
const result = listThreadsQuerySchema.safeParse({
271+
page: 0,
272+
perPage: 100,
273+
});
274+
275+
expect(result.success).toBe(true);
276+
if (result.success) {
277+
expect(result.data.metadata).toBeUndefined();
278+
expect(result.data.orderBy).toBeUndefined();
279+
}
280+
});
281+
254282
describe('orderBy parameter parsing', () => {
255283
it('should parse orderBy when passed as an object', () => {
256284
const result = listThreadsQuerySchema.safeParse({

packages/server/src/server/schemas/memory.ts

Lines changed: 122 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -25,92 +25,96 @@ export const optionalAgentIdQuerySchema = z.object({
2525
* Storage order by configuration for threads and agents (have both createdAt and updatedAt)
2626
* Handles JSON parsing from query strings
2727
*/
28-
const storageOrderBySchema = z.preprocess(
29-
val => {
30-
if (typeof val === 'string') {
31-
try {
32-
return JSON.parse(val);
33-
} catch {
34-
return undefined;
28+
const storageOrderBySchema = z
29+
.preprocess(
30+
val => {
31+
if (val === undefined) return val;
32+
if (typeof val === 'string') {
33+
try {
34+
return JSON.parse(val);
35+
} catch {
36+
return undefined;
37+
}
3538
}
36-
}
37-
return val;
38-
},
39-
z
40-
.object({
39+
return val;
40+
},
41+
z.object({
4142
field: z.enum(['createdAt', 'updatedAt']).optional(),
4243
direction: z.enum(['ASC', 'DESC']).optional(),
43-
})
44-
.optional(),
45-
);
44+
}),
45+
)
46+
.optional();
4647

4748
/**
4849
* Storage order by configuration for messages (only have createdAt)
4950
* Handles JSON parsing from query strings
5051
*/
51-
const messageOrderBySchema = z.preprocess(
52-
val => {
53-
if (typeof val === 'string') {
54-
try {
55-
return JSON.parse(val);
56-
} catch {
57-
return undefined;
52+
const messageOrderBySchema = z
53+
.preprocess(
54+
val => {
55+
if (val === undefined) return val;
56+
if (typeof val === 'string') {
57+
try {
58+
return JSON.parse(val);
59+
} catch {
60+
return undefined;
61+
}
5862
}
59-
}
60-
return val;
61-
},
62-
z
63-
.object({
63+
return val;
64+
},
65+
z.object({
6466
field: z.enum(['createdAt']).optional(),
6567
direction: z.enum(['ASC', 'DESC']).optional(),
66-
})
67-
.optional(),
68-
);
68+
}),
69+
)
70+
.optional();
6971

7072
/**
7173
* Include schema for message listing - handles JSON parsing from query strings
7274
*/
73-
const includeSchema = z.preprocess(
74-
val => {
75-
if (typeof val === 'string') {
76-
try {
77-
return JSON.parse(val);
78-
} catch {
79-
// Return invalid string to fail validation (z.array will reject string type)
80-
return val;
75+
const includeSchema = z
76+
.preprocess(
77+
val => {
78+
if (val === undefined) return val;
79+
if (typeof val === 'string') {
80+
try {
81+
return JSON.parse(val);
82+
} catch {
83+
// Return invalid string to fail validation (z.array will reject string type)
84+
return val;
85+
}
8186
}
82-
}
83-
return val;
84-
},
85-
z
86-
.array(
87+
return val;
88+
},
89+
z.array(
8790
z.object({
8891
id: z.string(),
8992
threadId: z.string().optional(),
9093
withPreviousMessages: z.number().optional(),
9194
withNextMessages: z.number().optional(),
9295
}),
93-
)
94-
.optional(),
95-
);
96+
),
97+
)
98+
.optional();
9699

97100
/**
98101
* Filter schema for message listing - handles JSON parsing from query strings
99102
*/
100-
const filterSchema = z.preprocess(
101-
val => {
102-
if (typeof val === 'string') {
103-
try {
104-
return JSON.parse(val);
105-
} catch {
106-
// Return invalid string to fail validation (z.object will reject string type)
107-
return val;
103+
const filterSchema = z
104+
.preprocess(
105+
val => {
106+
if (val === undefined) return val;
107+
if (typeof val === 'string') {
108+
try {
109+
return JSON.parse(val);
110+
} catch {
111+
// Return invalid string to fail validation (z.object will reject string type)
112+
return val;
113+
}
108114
}
109-
}
110-
return val;
111-
},
112-
z
113-
.object({
115+
return val;
116+
},
117+
z.object({
114118
dateRange: z
115119
.object({
116120
start: z.coerce.date().optional(),
@@ -120,24 +124,30 @@ const filterSchema = z.preprocess(
120124
})
121125
.optional(),
122126
roles: z.array(z.string()).optional(),
123-
})
124-
.optional(),
125-
);
127+
}),
128+
)
129+
.optional();
126130

127131
/**
128132
* Memory config schema - handles JSON parsing from query strings
129133
*/
130-
const memoryConfigSchema = z.preprocess(val => {
131-
if (typeof val === 'string') {
132-
try {
133-
return JSON.parse(val);
134-
} catch {
135-
// Return invalid string to fail validation (z.record will reject string type)
134+
const memoryConfigSchema = z
135+
.preprocess(
136+
val => {
137+
if (val === undefined) return val;
138+
if (typeof val === 'string') {
139+
try {
140+
return JSON.parse(val);
141+
} catch {
142+
// Return invalid string to fail validation (z.record will reject string type)
143+
return val;
144+
}
145+
}
136146
return val;
137-
}
138-
}
139-
return val;
140-
}, z.record(z.string(), z.unknown()).optional());
147+
},
148+
z.record(z.string(), z.unknown()),
149+
)
150+
.optional();
141151

142152
/**
143153
* Thread object structure
@@ -190,20 +200,23 @@ export const getMemoryConfigQuerySchema = agentIdQuerySchema;
190200
export const listThreadsQuerySchema = createPagePaginationSchema(100).extend({
191201
agentId: z.string().optional(),
192202
resourceId: z.string().optional(),
193-
metadata: z.preprocess(
194-
val => {
195-
if (typeof val === 'string') {
196-
try {
197-
return JSON.parse(val);
198-
} catch {
199-
// Return invalid string to fail validation (z.record will reject string type)
200-
return val;
203+
metadata: z
204+
.preprocess(
205+
val => {
206+
if (val === undefined) return val;
207+
if (typeof val === 'string') {
208+
try {
209+
return JSON.parse(val);
210+
} catch {
211+
// Return invalid string to fail validation (z.record will reject string type)
212+
return val;
213+
}
201214
}
202-
}
203-
return val;
204-
},
205-
z.optional(z.record(z.string(), z.any())),
206-
),
215+
return val;
216+
},
217+
z.record(z.string(), z.any()),
218+
)
219+
.optional(),
207220
orderBy: storageOrderBySchema,
208221
});
209222

@@ -226,11 +239,14 @@ export const listMessagesQuerySchema = createPagePaginationSchema(40).extend({
226239
orderBy: messageOrderBySchema,
227240
include: includeSchema,
228241
filter: filterSchema,
229-
includeSystemReminders: z.preprocess(val => {
230-
if (val === 'true') return true;
231-
if (val === 'false') return false;
232-
return val;
233-
}, z.boolean().optional()),
242+
includeSystemReminders: z
243+
.preprocess(val => {
244+
if (val === undefined) return val;
245+
if (val === 'true') return true;
246+
if (val === 'false') return false;
247+
return val;
248+
}, z.boolean())
249+
.optional(),
234250
});
235251

236252
/**
@@ -278,20 +294,23 @@ export const getMemoryStatusNetworkQuerySchema = agentIdQuerySchema;
278294
export const listThreadsNetworkQuerySchema = createPagePaginationSchema(100).extend({
279295
agentId: z.string().optional(),
280296
resourceId: z.string().optional(),
281-
metadata: z.preprocess(
282-
val => {
283-
if (typeof val === 'string') {
284-
try {
285-
return JSON.parse(val);
286-
} catch {
287-
// Return invalid string to fail validation (z.record will reject string type)
288-
return val;
297+
metadata: z
298+
.preprocess(
299+
val => {
300+
if (val === undefined) return val;
301+
if (typeof val === 'string') {
302+
try {
303+
return JSON.parse(val);
304+
} catch {
305+
// Return invalid string to fail validation (z.record will reject string type)
306+
return val;
307+
}
289308
}
290-
}
291-
return val;
292-
},
293-
z.optional(z.record(z.string(), z.any())),
294-
),
309+
return val;
310+
},
311+
z.record(z.string(), z.any()),
312+
)
313+
.optional(),
295314
orderBy: storageOrderBySchema,
296315
});
297316

0 commit comments

Comments
 (0)