← All context files

lite/query-parsing.md

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