Skip to content

fix(hooks): give count its own query type, measured against the engine - #1675

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-1528-hook-count-query-split
Sep 6, 2026
Merged

fix(hooks): give count its own query type, measured against the engine#1675
os-steve merged 1 commit into
mainfrom
claude/issue-1528-hook-count-query-split

Conversation

@os-steve

@os-steve os-steve commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Closes #1528

src/objects/_hook-api.ts declared all three ctx.api read methods as one
HookQuery (where / fields / top). The engine does not accept that key set
on all three, so the file blessed two calls at compile time that throw on every
invocation.

The measurement — taken here, not copied from the card

The card's table was taken on 17.2.0; this repo pins 17.3.0. Zone 1 rule 4
required re-measuring, so every cell below was read off the engine's own
unknown-option guard on the pinned packages, on the object the kernel injects as
ctx.api (a real ScopedContext over a real ObjectQL engine on the in-memory
driver, the same apparatus test/hook-query-predicate.test.ts uses).

Verbatim, from the engine:

count('crm_account') does not recognise option 'top'. The engine executes none of it,
so the call would succeed with the option silently ignored (#4371).
Legal keys for count: context, where.
method legal key set, verbatim from the guard
count context, where
find bypassTenantAudit, context, expand, fields, limit, offset, orderBy, preserveAudit, search, searchFields, tenantId, tenantIds, timezone, transaction, where
findOne same as find

Measured, each separately rather than one inferred from the other:

  • count({ where, fields: ['name'] })throws, same message word for word;
  • count({ where, top: 5000 })throws;
  • count({ where }) and count({ context, where }) → OK (so context really is legal);
  • count({ where, nonsense_key_xyz }) → throws — negative control, the guard is alive, so the two greens above are evidence of something.

top is still a live alias of limit on 17.3.0 (Zone 2 item 3): over three
rows, find({ top: 2 }) and find({ limit: 2 }) both return 2 while find({})
returns 3, and find({ top: 2, limit: 3 }) throws Conflicting options … 'limit', 'top' are spellings of the same parameter (canonical 'limit') — the same fold
mechanism that makes filter an alias of where. So find / findOne need no
change.

No drift from the card's 17.2.0 reading. Both key sets came back identical.

The change

count takes a new HookCountQuery carrying only where; find / findOne
keep HookQuery unchanged. HookQuery was deliberately not narrowed to the
three-method intersection (Zone 1 rule 2) — that would break the four live
find({ fields, top }) call sites in campaign.hook.ts (×3) and
campaign_member.hook.ts (×1), trading one latent defect for four real ones.

The new type is written as a continuation of HookUpdateOptions /
HookDeleteOptions, which already carry the "deliberately narrow, excess-property
checking rejects the rest at the call site" reasoning, and it records the verbatim
engine reading it was derived from so the next author does not have to rediscover
how to take that measurement.

No call site moves — the card stays latent

A structural scan of every count() argument bag under src/ (brace-balanced, so
multi-line calls are read whole): 23 call sites, 0 passing fields, top, or
any other key outside the legal set, and none spreading
. The reclassify trigger
in the R30 grading (any count() passing fields/top ⇒ regrade to Bug) did
not fire. tsc --noEmit agrees — it is green with zero call-site edits.

Reverse verification — the gate can actually fail

A green typecheck proves nothing on its own, so both legs were run from the
committed state, with the mutation proven on disk by grepping the injected text
and the restore proven by blob-hash equality against HEAD plus an empty
git diff HEAD:

leg tree tsc --noEmit
1 new HookCountQuery + two illegal keys injected into real count() call sites exit 1
2 same mutation, type rolled back to pre-fix HookQuery exit 0

Leg 1, verbatim:

src/objects/product.hook.ts(65,85): error TS2353: Object literal may only specify known properties, and 'fields' does not exist in type 'HookCountQuery'.
src/objects/product.hook.ts(66,79): error TS2353: Object literal may only specify known properties, and 'top' does not exist in type 'HookCountQuery'.

Leg 2 is the defect itself, reproduced: the old declaration compiled both calls
clean. That is the pair the fix is for.

Gates

pnpm verify fully green on e6b9eef9 — all eight stages: validate
(passed, 1265ms) · typecheck (exit 0) · lint · lint:i18n-gate (0
i18n/missing-*) · hygiene (clean) · hygiene:tokens (clean) · build
(complete, 1389ms) · test (161 files, 3402 passed, 1 skipped), which
includes test/hook-query-predicate.test.ts unchanged and passing.

Token ratchet moved +14 tokens (business semantics ~85,018 → ~85,032, ceiling
~100,000) — the three lines of interface; the docblock is comment-stripped by
design.

Per Zone 1 rule 5, no gate and no test file is added, and nothing under
test/** is touched (epic #1579's territory). The card's suggestion to pin this
in test/hook-query-predicate.test.ts was explicitly not adopted: the split is
self-proving at compile time, and tsc --noEmit inside pnpm verify is this
change's real gate — demonstrated above rather than asserted.

One changeset, patch.

🤖 Generated with Claude Code

https://claude.ai/code/session_018xtjdpZFjgWh4Ad9Wcx68J


Generated by Claude Code

`src/objects/_hook-api.ts` declared all three `ctx.api` read methods as one
`HookQuery` (`where` / `fields` / `top`). The engine does not accept that key
set on all three: `count`'s legal set is the predicate and nothing else, so
`count({ where, fields })` and `count({ where, top })` compiled and threw on
every invocation.

Measured against the pinned `@objectstack` packages (17.3.0), on the object the
kernel injects as `ctx.api`, by handing each key to the engine and reading its
unknown-option guard, which prints the legal set verbatim:

    count('crm_account') does not recognise option 'top'. The engine executes
    none of it, so the call would succeed with the option silently ignored
    (#4371). Legal keys for count: context, where.

`fields` produces the same message word for word; the two were measured
separately rather than one inferred from the other. `find` / `findOne` accept
both — their legal set is `bypassTenantAudit, context, expand, fields, limit,
offset, orderBy, preserveAudit, search, searchFields, tenantId, tenantIds,
timezone, transaction, where` — and `top` reaches them as a declared alias of
`limit`, the same fold that makes `filter` an alias of `where`.

So `count` takes a new `HookCountQuery` carrying only `where`, and `find` /
`findOne` keep `HookQuery` unchanged. `HookQuery` was deliberately NOT narrowed
to the three-method intersection: that would break the four live
`find({ fields, top })` call sites in `campaign.hook.ts` and
`campaign_member.hook.ts`, trading one latent defect for four real ones.

No call site moves — all 23 `count()` call sites under `src/` pass only `where`,
so the defect was latent. The new type is enforced by `tsc --noEmit`, already in
`pnpm verify`; no gate and no test file is added. It reads as the continuation
of the reasoning `HookUpdateOptions` / `HookDeleteOptions` already carry, and
records the verbatim engine reading it was derived from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018xtjdpZFjgWh4Ad9Wcx68J
@vercel

vercel Bot commented Sep 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
hotcrm Ignored Ignored Sep 6, 2026 9:35am UTC

Request Review

@github-actions github-actions Bot added the metadata Declarative metadata — schema, security posture, UI surfaces label Sep 6, 2026
@os-steve
os-steve marked this pull request as ready for review September 6, 2026 09:40
@os-steve
os-steve added this pull request to the merge queue Sep 6, 2026
Merged via the queue into main with commit bc35232 Sep 6, 2026
10 checks passed
os-steve pushed a commit that referenced this pull request Sep 6, 2026
Two reasons, neither of which changes this PR's own scope.

1. The head commit carries a permanently-failed `Check Changeset` run. That
   run started 7s after the PR was opened; the `skip-changeset` label landed
   1s later, but the job had already read a payload without it, so the
   workflow's `if: !contains(labels, 'skip-changeset')` guard let the job run
   and it failed 8s after starting. The `labeled` re-run skipped correctly,
   but the failed run stays attached to that head for ever and no event
   re-evaluates it. A new head is the only way to clear it without a rebase
   or a force-push.

2. The base had moved 18 commits since the PR was opened, so the last green
   `Build and Test` measured a base that no longer exists.

One of those 18 touches the surface this brief documents: #1689 re-scoped the
pin-claim comment in `src/objects/_hook-api.ts` from "measured on 17.2.0" to
"first taken on 17.2.0, re-taken on the current pin 17.3.0". The brief already
states 17.3.0, so the merge agrees with it rather than contradicting it. #1675,
which gave `count` its own `HookCountQuery`, was already in this branch's
history when it was cut and the brief was written against it.

The diff against main is unchanged: `.github/instructions/logic.md` plus
`.github/instructions/metadata.md`.

Co-authored-by: Claude
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

metadata Declarative metadata — schema, security posture, UI surfaces

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HookQuery declares fields and top for all three read methods, but count throws on both

2 participants