fix(hooks): query by where, not filter, on ctx.api reads - #573
Merged
Conversation
Eighteen `ctx.api` calls across eight `*.hook.ts` files passed their
predicate as `filter:`. The ObjectQL kernel does not accept that key on
every read path, and drops it without raising:
- `find` normalizes `filter` -> `where`, so those calls worked by luck.
- `findOne` spreads the query into the AST (`{ ...query, limit: 1 }`) and
never aliases, so the predicate vanished and the call returned the
object's FIRST ROW — for any argument, including one matching nothing.
It never returned null and never threw.
- `count` reads `query.where` explicitly, so the predicate vanished and
the call counted the ENTIRE object.
Verified against a real ObjectQL 16.1.0 engine, not the test harness.
Impact of the mis-reads:
- opportunity/quote line items defaulted `list_price` / `unit_price` from
the first product in the catalog instead of the chosen one — wrong money
on the record.
- quote acceptance, won-opportunity and contract-activation evaluated
their "already in the target state?" gate against an unrelated record
while writing to the correct one.
- case escalation assigned the follow-up task to the first account's owner.
- account and product delete guards counted every row in the object, so
they blocked deletes with no real references and reported a made-up
count in the error message.
- campaign ROI snapshots stored whole-table opportunity and lead counts as
campaign attribution.
`HookQuery` no longer declares `filter?`, so this fails at compile time now
rather than in the data. `src/actions/lead.actions.ts` is normalized too —
behaviour-identical there (it is a `find`), but it was the seed of the next
copy-paste.
The suite could not have caught this: `test/helpers/hook-harness.ts`
resolved its predicate as `q.filter ?? q.where ?? {}`, making the stand-in
more permissive than the kernel it stands in for, so every affected hook
tested green. The harness now throws on `filter`, and the new
`test/hook-query-predicate.test.ts` pins the contract against a real
in-memory kernel — including the kernel's silent-drop behaviour, so the trap
stays documented — plus a source scan that fails any `*.hook.ts`
reintroducing the key.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7h5XeGgNPBqNuV6LCRWpg
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
Resolves conflicts in the two `*_line_item.hook.ts` files. main's #570 extracted the two near-verbatim price-fill handlers into a shared `_line-item-price-fill.ts`; that extraction wins, and the `where:` fix moves into the shared module — which had carried the `filter:` along with it. Widens the source scan in `test/hook-query-predicate.test.ts` from `src/objects/*.hook.ts` to every `.ts` in that directory. The narrow glob would have missed `_line-item-price-fill.ts` entirely: hook bodies get factored into shared modules, and a dropped predicate riding along in one of those is exactly as broken while matching no `*.hook.ts` pattern. The scan now asserts it can see that file, so the coverage cannot silently narrow again. The rollup fixes in both line-item hooks merged cleanly and are unchanged. pnpm verify: 25 files, 480 passed | 2 skipped. Coverage thresholds hold. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7h5XeGgNPBqNuV6LCRWpg
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Hook handlers were reading records by a query key the kernel silently drops.
Seventeen hook-side
ctx.apicalls passed their predicate asfilter:insteadof
where:. On the ObjectQL 16.1.0 read paths that key is not a synonym — andthe mismatch produces no error, no warning, and no empty result. It produces the
wrong record.
Reproduced against a real
ObjectQLengine (in-memory driver, realScopedContext— the exact object the kernel injects asctx.api), two rowsseeded, asking for the second by id:
Root cause is per-method, which is why it looked like
filter"mostly worked" —in
@objectstack/objectql@16.1.0,dist/index.js:findif (ast.filter != null && ast.where == null) ast.where = ast.filterfindOneconst ast = { object, ...query, limit: 1 }limit: 1→ first rowcountast: { object, where: query?.where }whereonly → predicate gone → whole-object countType of Change
Related Issues
n/a — found by inspection while auditing the L2 hook sandbox read path.
Changes Made
src/objects/_hook-api.ts— droppedfilter?fromHookQuery. This is theroot fix: the compiler now rejects the spelling at authoring time instead of
leaving it to be discovered in the data. Removing it surfaced every call site
at once, including ten the original report had not identified.
account,campaign,case,contract,opportunity,opportunity_line_item,product,quote,quote_line_item, and the shared_line-item-price-fill.ts.test/helpers/hook-harness.ts— the harness resolved its predicate asq.filter ?? q.where ?? {}, accepting both spellings. That made the stand-inmore permissive than the kernel it stands in for, so every affected hook
tested green while querying wrong in production. It now throws on
filter.test/hook-query-predicate.test.ts(new) — pins the contract against areal kernel rather than the harness, characterises the kernel's
silent-drop so the trap stays documented, and adds a source scan over
src/objects/**.tsthat fails any file reintroducing the key.src/actions/lead.actions.ts— normalized towhere:. Behaviour-identical(it is a
find), but it was the seed of the next copy-paste.Impact — what was actually computed wrong
The hypothesis named four hooks; the compiler found eight files plus a shared
module. The two most consequential were not in the original list:
_line-item-price-fill.ts(both line-item objects)list_pricelist_price— andunit_priceon create — defaulted from the first product in the catalog, not the one the rep chose. Silently mispriced line items flow into opportunity amount and quote totals via the rollups.product.hook.tscountof referencing line itemsaccount.hook.tscountof open opportunitiescampaign.hook.tscountof opportunities / converted leadsquote.hook.tsclosed_wonto the correct one. Either closes a deal the gate should have stopped, or skips one it should have closed.opportunity.hook.ts,contract.hook.tstype !== 'customer'case.hook.tsowneropportunity_line_item.hook.ts,quote_line_item.hook.ts(rollups)Writes were consistently targeted by explicit id, so no hook wrote to the
wrong record. The damage is in the reads: gates decided on someone else's row,
and money and metrics computed from it.
Note on the
mainmergemainadvanced while this was open, and #570 extracted the two near-verbatimprice-fill handlers into a shared
src/objects/_line-item-price-fill.ts. Thatextraction wins in the merge, and it had carried the
filter:along with it— so the fix moves into the shared module.
That is also why the source scan covers every
.tsundersrc/objects/ratherthan the
*.hook.tsglob it started with: a guard keyed to a filenameconvention would have missed the one instance most likely to survive a
refactor. The scan now asserts it can see
_line-item-price-fill.ts, so itscoverage cannot silently narrow again. The rollup fixes in both line-item hooks
merged cleanly and are unchanged.
Testing
pnpm test) — 25 files, 480 passed / 2 skippedpnpm lint)pnpm build)ObjectQLengine before and after the fixFull
pnpm verify(validate → typecheck → lint → hygiene → build → test) isgreen on the merged tree. Coverage thresholds hold (lines 99.6, statements 95.2,
functions 95.1, branches 80.3).
scripts/check-stackblitz-lock.mjsandchangeset status— the two CI stepspnpm verifydoes not cover — also pass.The new guards were confirmed to have teeth, not just to pass. Reverting a
single hook call site back to
filter:fails four tests: three behavioural onesin
hooks-runtime-sales.test.ts(via the hardened harness) and the source scan,which names the file and prints the offending line. Those same three tests
passed before this PR with the bug present, which is the clearest evidence that
the harness was masking it. Reverting the shared module's line was checked
separately and fails the widened scan by name.
Screenshots
n/a
Checklist
.changeset/hook-query-where-not-filter.mdAdditional Notes
Deliberately out of scope — a platform-side report was written up separately.
The framework behaviour underneath this — an unsupported predicate key is
dropped silently, and
findOnethen returns the first row rather than raisingor returning
null— is a@objectstack/objectqlconcern, not a HotCRM one,and is not patched here. This PR only stops HotCRM from tripping over it. Two
things make that behaviour worth fixing upstream:
findaliasesfilterwhilefindOneandcountdo not, so the keyappears to work until it is used on the path where being wrong is silent.
ObjectQL.createContext's own doc comment demonstrates the broken spelling:await users.find({ filter: { status: 'active' } }).The characterisation block in
test/hook-query-predicate.test.tswill startfailing if and when the kernel is fixed — that is intentional, and the comment
there says what to do about it.
Closes #574