Skip to content

Query execution mutates the caller's conditions array — sort pseudo-condition leaks in and poisons reuse with a misleading type error #1572

Closed

Description

@kylebernhardy

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)

  1. resources/Table.ts:2727-2728 — orderAlignedCondition = { ...sort, comparator: 'sort' }; conditions.push(orderAlignedCondition) where conditions is target.conditions (caller's array by reference).
  2. resources/Table.ts:~2756 — conditional splice-out: only when the pseudo-condition is not conditions[0] after reordering.
  3. resources/search.ts:~994-1075 — estimateCondition writes estimated_count onto caller-owned entries.
  4. On reuse, prepareConditions' coercion loop (Table.ts:2637-2646) has no comparator === 'sort' exemption — unlike the query-string parse path which has exactly that guard (search.ts:146) — so the valueless pseudo-condition reaches coerceType (Table.ts:5479) and throws at Table.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.

Activity

  1. self-assigned this
    on Jul 2, 2026
  2. kriszyp commented on Jul 6, 2026

    @kriszyp
    Member

    Confirmed + escalated from exploratory QA (harper 228eacc0f): the in-place conditions mutation is process/module-sticky, not request-scoped.

    search()/get() take target.conditions by reference (resources/Table.ts search() ~2594, no copy) and mutate it in place via prepareConditions (~2612). A sort on an indexed attr with no existing condition does conditions.push({...sort, comparator:'sort'}) (~2741) onto the caller's array. So a module-level const BASE = [...] reused across queries gets poisoned:

    • after one sort query, BASE carries {attribute:'createdAt', descending:true, comparator:'sort', estimated_count:N};
    • a later reuse (even a sort-less query) re-runs prepareConditions, which coerces the valueless pseudo-condition (typed Date attr → coercion fires regardless of comparator) → coerceType(undefined, DateAttr) throws SyntaxError: 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 BASE already inherits the leak. One sort query permanently breaks all reuses of a shared conditions constant for that worker. Not estimate-dependent (structural). Fix: copy conditions before mutating.

    Repro: local qa-scratch/qa449-conditions-mutation.test.ts (+ the existing qa427 sibling).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Fields

Priority

None yet

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions