Repository navigation
Query execution mutates the caller's conditions array — sort pseudo-condition leaks in and poisons reuse with a misleading type error #1572
Description
Summary
Executing Table.get({ conditions, sort }) mutates the caller's conditions array in place: the planner annotates each entry with estimated_count and pushes the sort into the array as a pseudo-condition ({attribute, descending, comparator: 'sort', estimated_count} — no value). If the caller reuses the array in a later query, the leftover pseudo-condition is treated as a real condition on the sort attribute with value: undefined, and the query throws a misleading type error:
Invalid value for attribute createdAt: "undefined", expecting Date
Verbatim before/after of the same array object:
before: [{"attribute":"userEmail","value":"HDB_ADMIN"}]
after: [{"attribute":"userEmail","value":"HDB_ADMIN","estimated_count":18},
{"attribute":"createdAt","descending":true,"comparator":"sort","estimated_count":9.5}]
Why it masquerades as nondeterminism
Cleanup for the pseudo-condition exists but is conditional: it is spliced back out only when it did not land first after orderConditions reorders by estimated_count. When the sort attribute's index has the smallest estimate (here: createdAt 9.5 vs userEmail 18), it lands first, is used as the index-order provider, and is never removed from the caller's array. Whether reuse breaks therefore depends on live index estimates — we spent a day chasing this as a phantom "POST-transaction bug" because the failure moved with call order. Module-level shared query constants — a pattern users naturally write — break mysteriously.
Repro (2 queries, deterministic given the estimate condition above)
const conditions = [{ attribute: 'userEmail', value: 'someone' }]; // userEmail @indexed
// query 1 — works, silently mutates `conditions`
for await (const _ of await UserWeather.get({
conditions,
sort: { attribute: 'createdAt', descending: true }, // createdAt @indexed @createdTime
})) { /* ... */ }
// query 2 — throws: Invalid value for attribute createdAt: "undefined", expecting Date
for await (const _ of await UserWeather.get({ conditions, limit: 5 })) { /* ... */ }Fresh object literals per query always work; reusing a conditions array between two sort-less queries is also fine.
Root cause chain (5.1.15)
resources/Table.ts:2727-2728—orderAlignedCondition = { ...sort, comparator: 'sort' }; conditions.push(orderAlignedCondition)whereconditionsistarget.conditions(caller's array by reference).resources/Table.ts:~2756— conditional splice-out: only when the pseudo-condition is notconditions[0]after reordering.resources/search.ts:~994-1075—estimateConditionwritesestimated_countonto caller-owned entries.- On reuse,
prepareConditions' coercion loop (Table.ts:2637-2646) has nocomparator === 'sort'exemption — unlike the query-string parse path which has exactly that guard (search.ts:146) — so the valueless pseudo-condition reachescoerceType(Table.ts:5479) and throws atTable.ts:5522.
Suggested fix
Plan on a copy (conditions.slice() before push/annotation), or splice the pseudo-condition out unconditionally; defense in depth: give the prepareConditions coercion loop the same comparator === 'sort' exemption the parse path already has.
Possibly related: intermittent empty/short iterator (not yet reproducible on demand)
While chasing the above we repeatedly observed a single-condition indexed query ({conditions: [{attribute: 'userEmail', value}]}, table with 18+ matching rows) silently yield 0 rows — and on other occasions 4 or 6 — with no error, clustering near process start, healing minutes later. Ruled out empirically: HTTP method, a preceding cache-source fetch in the same request, the mutation above (zero-row runs occurred on an array's first use), table-object identity, and row expiration. Single conditions skip planner reordering (Table.ts:2690) and scan the attribute index directly, so this points at per-worker lazy index/store initialization or a read-txn against a not-yet-ready handle. Not observed in 3 fresh-process samples on 5.1.15 (it was intermittent on 5.1.6 too — inconclusive). Flagging in case it matches a known 5.1.x pattern; happy to run instrumented builds.
Environment
Harper 5.1.6, mutation + error re-verified byte-identical on 5.1.15. Node v24.16.0 (type-stripped TS resources), macOS, single instance, harper dev. Table shape: String PK, userEmail: String! @indexed, createdAt: Date @indexed @createdTime, table-level expiration.
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Activity
kriszyp commentedon Jul 6, 2026
Confirmed + escalated from exploratory QA (harper
228eacc0f): the in-placeconditionsmutation is process/module-sticky, not request-scoped.search()/get()taketarget.conditionsby reference (resources/Table.tssearch()~2594, no copy) and mutate it in place viaprepareConditions(~2612). Asorton an indexed attr with no existing condition doesconditions.push({...sort, comparator:'sort'})(~2741) onto the caller's array. So a module-levelconst BASE = [...]reused across queries gets poisoned:BASEcarries{attribute:'createdAt', descending:true, comparator:'sort', estimated_count:N};prepareConditions, which coerces the valueless pseudo-condition (typed Date attr → coercion fires regardless of comparator) →coerceType(undefined, DateAttr)throwsSyntaxError: Invalid value for attribute createdAt: "undefined", expecting Date(~5601).Escalation beyond the original report: the poison persists in the worker for its lifetime, so a later independent request's own fresh clone of
BASEalready inherits the leak. One sort query permanently breaks all reuses of a shared conditions constant for that worker. Not estimate-dependent (structural). Fix: copyconditionsbefore mutating.Repro: local
qa-scratch/qa449-conditions-mutation.test.ts(+ the existing qa427 sibling).test: promote six measured regression anchors
test: promote five regression anchors, each measured green on main an…