lite – Query String Parsing, Value Revival and parse: false
req.query in a lite route is not the raw query string. lite runs every
value through reviveQuery (src/parseQuery.ts) before your route sees it, and
that coercion is the cause of a whole family of "the row is there but the
WHERE matches nothing" bugs.
Applies to lite v6 (@kyd/lite) in both the Node dev server and the Classic ASP
build — the parsing is identical in both.
What revival does to your values
reviveQuery walks the parsed query object and rewrites every scalar:
| Query string | req.query value |
Type |
|---|---|---|
?id=0869 |
869 |
number |
?id=00123 |
123 |
number |
?id=12.50 |
12.5 |
number |
?flag=true / ?flag=false |
true / false |
boolean |
?x=null |
null |
null |
?x=undefined |
undefined |
undefined |
?at=2026-08-27T00:00:00Z |
Date object |
Date |
?at=2026-08-27 |
'2026-08-27' |
string (date-only isn't ISO enough) |
?id=0869A |
'0869A' |
string |
The rule for numbers is /^(\d+|\d*\.\d+)$/ → parseFloat. Anything made up
entirely of digits becomes a number.
The trap: varchar identifiers that look numeric
This is the one that bites. A varchar key whose value happens to be all digits loses its leading zeros and its string-ness on the way in:
// GET /security/sloc?id=0869&groupId=<guid> (DELETE via method override)
const { id } = req.query; // 869 — a number, zero gone
execute(
`DELETE FROM ${db}.LPA_Groups_SLOCS` +
` WHERE SLOC_ID = ${sanitize(String(id))}` // '869' — matches nothing
);
String(id) does not save you. By the time the route runs, the leading zero
is already gone — you are stringifying 869, not '0869'.
There is a second failure mode even without leading zeros: sanitize(869)
emits a bare numeric literal, so SQL Server applies numeric conversion to the
whole varchar column and the statement fails outright the moment any row in
that column holds a non-numeric value.
Anything varchar and digit-shaped is exposed: SLOCs, force elements, stock codes, NSNs, PMKeys, unit codes, cost centres, phone numbers.
parse: false — where it works
lite has an opt-out. It is a filter property, declared in the filter schema
alongside key, not and where (src/.internal/validateFilter.ts). Set it
inside a filter object and the sibling filter-type values in that object are
left as strings:
?filter[0][key]=id&filter[0][equals]=0869&filter[0][parse]=false
→ { filter: [ { key: 'id', equals: '0869', parse: false } ] } ✅ string
?filter[0][key]=id&filter[0][equals]=0869
→ { filter: [ { key: 'id', equals: 869 } ] } ❌ number
It covers in arrays and between pairs too:
?filter[0][key]=id&filter[0][in][]=0869&filter[0][in][]=0870&filter[0][parse]=false
→ { filter: [ { key: 'id', in: ['0869', '0870'], parse: false } ] } ✅
It applies to any of the filter-type keys — equal, equals, greaterThan,
lessThan, greaterThanOrEqualTo, lessThanOrEqualTo, notEqualTo,
notLessThan, notGreaterThan, like, in, between, isNull, isNotNull
— plus the v1-style value key.
parse: false — where it does NOT work
A top-level parse=false protects nothing. The check only fires for keys
that are a filter type or value, and only when parse is a sibling inside
the same object:
?id=0869&parse=false
→ { id: 869, parse: false } ❌ id still revived
So for a plain param like ?id=0869 — the common shape for a DELETE that
identifies its row by id — the escape hatch is unavailable. Two ways out:
Option A — send it as a filter object
?id[value]=0869&id[parse]=false
→ { id: { value: '0869', parse: false } }
Then read req.query.id.value in the route. Works, but the client has to know
to do it, and every caller of that endpoint has to agree.
Option B — read the raw query string (server-side, no client change)
req.queryString is the untouched query string, available on the request in
both runtimes. Pull the value out of it yourself:
/**
* Reads a query parameter straight off the raw query string, skipping lite's
* value revival. Use for varchar identifiers that may be all digits.
*/
export default function rawQueryParam(req, name) {
const queryString = req && req.queryString;
if (typeof queryString !== 'string' || !queryString) return undefined;
const decode = (part) => {
try {
return decodeURIComponent(part.replace(/\+/g, ' '));
} catch (e) {
return part;
}
};
const pairs = queryString.split('&');
for (let i = 0; i < pairs.length; i++) {
const eq = pairs[i].indexOf('=');
const key = eq === -1 ? pairs[i] : pairs[i].slice(0, eq);
if (decode(key) === name) {
return eq === -1 ? '' : decode(pairs[i].slice(eq + 1));
}
}
return undefined;
}
const id = rawQueryParam(req, 'id'); // '0869' — intact
const { groupId } = req.query; // GUIDs are unaffected, req.query is fine
This is JScript-safe (no URLSearchParams, no Array.from, no
String.prototype.startsWith), so it survives the goldfish ASP build.
Bodies are not revived
req.body is parsed as JSON and keeps its types. {"id": "0869"} arrives
as the string '0869'.
This is why the same value often works on create and fails on delete: rapid.js
puts withParams() in the body for POST/PUT/PATCH but in the query
string for GET/DELETE. The insert stores '0869' correctly; the delete then
looks for 869 and finds nothing.
If you are designing a new endpoint and the identifier is varchar, prefer carrying it in the body.
Checklist
- Is the column varchar? Can its values be all digits? → don't read it from
req.query. - Using lite
filterparams? → addfilter[n][parse]=false. - Plain top-level param? →
parse=falsewill not help; readreq.queryStringor move the value into the body. String(x)on a revived value is a no-op fix — the damage happened before your route ran.- GUIDs, and anything with a non-digit character, are unaffected.