Skip to content

fix(objectql): every engine verb folds filterwheredelete({filter}) no longer empties the object (#4346) - #4349

Closed
os-zhuang wants to merge 2 commits into
mainfrom
claude/query-top-limit-alias-fold
Closed

fix(objectql): every engine verb folds filterwheredelete({filter}) no longer empties the object (#4346)#4349
os-zhuang wants to merge 2 commits into
mainfrom
claude/query-top-limit-alias-fold

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes #4346. Started as the #3795 follow-up on the sixth alias pair (top/limit) and turned up a P1 one layer down: the filterwhere fold existed in one of six engine verbs.

What was broken

ObjectQL.find() folds the alias, and its comment says why it must — the driver AST understands where only, so an unfolded filter is not a narrower query, it is no query. The five siblings never got that fold.

Measured against a real ObjectQL + a driver that applies ast.where (three rows: a: open, b: done, c: done), predicate passed as filter:

call before after
find({filter: done}) [b, c] unchanged
findOne({filter: done}) a — a row that does not match b
count({filter: done}) 3 2
aggregate({filter: done, …}) whole object matched rows
update(data, {filter: done, multi}) all three rows rewritten only b, c
delete({filter: done, multi}) object emptied only b, c deleted

The write verbs reached driver.updateMany / deleteMany with ast.where === undefined, which every driver reads as "no predicate". This is not a read over-grant like the ADR-0057 finding that put the fold in find() — it is an unbounded write.

Why it was reachable

ScopedContext — the cross-object API handed to L2 hook bodies as ctx.api.object('x') — forwards its argument bag verbatim, every parameter typed any:

async findOne(query: any = {}) { return this.engine.findOne(this.objectName, { ...query, context: this.context }); }
async delete(options: any = {}) { return this.engine.delete(this.objectName, { ...options, context: this.context }); }

and the spec's own hook TSDoc taught the broken call — packages/spec/src/data/hook.zod.ts:

 *   const admin = await users.findOne({ filter: { role: 'admin' } });

So a hook author following the documented example got an arbitrary user, and a cleanup hook doing users.delete({ filter: …, multi: true }) emptied the object. That example is corrected to where here. The deprecated DataEngine{Query,Update,Delete,Count}OptionsSchema also still declare filter for exactly these verbs, so "callers should not pass it" was never the contract.

The fix

One call per entry point to the shared #3795 machinery (foldQueryAliasSlots + the new ENGINE_FILTER_ALIAS_SLOTS), rather than five more copies of find's guard — the point of #3795 was to decide precedence once, and the engine is the third reader that sweep did not reach. Details that matter:

The sixth pair, same pass

top is documented as "Alias for limit (OData compatibility)" on both QuerySchema and EngineQueryOptionsSchema, so limit is canonical — but protocol.ts folded it unguarded (options.limit = Number(options.top), the alias overwriting the canonical key) while engine.find kept limit. {top: 1, limit: 3} answered 1 over HTTP and 3 through a direct engine call: #3795's exact divergence on the pair its scope note excluded. Both now read ENGINE_QUERY_ALIAS_SLOTS.

top deliberately stays a declared AST key rather than joining RPC_QUERY_ALIAS_SLOTS: those five are deprecated RPC keys the parse transform drops from its output, and removing a declared key from the parsed type is a breaking change that deserves its own decision. Giving it one precedence did not have to wait for that.

Deliberately not in scope

The engine still does not fold select/sort/skip/populate, which the deprecated query-options shape also declares. Those fail narrow (an ignored projection returns more columns, an ignored sort returns arbitrary order) rather than wide, and sort/populate need value-shape lowering the engine has no equivalent of — doing that inside a P1 fix is how you ship a second bug. Worth its own pass; noted on #4346.

Verification

  • 15 new engine pins (engine-option-alias-fold.test.ts), including two that assert the write verbs touch only matched rows — the regression here is silent and destructive, and the suite had nothing covering it.
  • Protocol pins for toplimit folding in the engine's direction, $top still reaching the same slot, and the conflict rejection.
  • objectql 1411 / spec 7156 / metadata-protocol 136 green; full pnpm test 132/132 tasks green.
  • check:generated 8/8 green; api-surface.json regenerated (+2 exports).
  • Changeset: patch @objectstack/objectql + @objectstack/metadata-protocol, minor @objectstack/spec (new exports).

Behaviour changes, all toward one answer: a predicate passed as filter now actually applies on every verb, and two spellings of one slot with different values are refused instead of silently resolved. A single spelling, and redundant identical spellings, behave exactly as before.

Refs #3795, #4181, #3713, ADR-0057.

🤖 Generated with Claude Code

…filter})` no longer empties the object (#4346)

The driver AST understands `where` only, so an unfolded `filter` is not a
narrower query — it is no query. `find()` folded it (with a comment saying
exactly that, from the ADR-0057 over-grant); `findOne`/`count`/`aggregate`/
`update`/`delete` never did. Measured: findOne returned a non-matching row,
count counted the whole object, and the write verbs reached updateMany /
deleteMany with no predicate — rewriting every row, emptying the object.

Reachable from the documented surface, not just internals: ScopedContext
(`ctx.api.object('x')` in an L2 hook) forwards its bag verbatim with every
param typed `any`, and the spec's own hook TSDoc taught
`findOne({ filter: ... })` — corrected here.

Each entry point now makes one call to the shared #3795 fold
(foldQueryAliasSlots + ENGINE_FILTER_ALIAS_SLOTS) instead of five more copies
of find's guard, and copies the caller's bag rather than mutating it.

Same pass, the sixth alias pair: `top` is declared as the alias of `limit`,
but protocol.ts overwrote `limit` with it unguarded while the engine kept
`limit` — one query, two answers by path. Both read ENGINE_QUERY_ALIAS_SLOTS
now; `top` stays a declared AST key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 31, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 31, 2026 8:49am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:data tests tooling size/m labels Jul 31, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/metadata-protocol, @objectstack/objectql, @objectstack/spec.

110 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via @objectstack/spec)
  • content/docs/automation/connectors.mdx (via @objectstack/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/metadata-protocol, @objectstack/objectql, packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql, @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via packages/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/metadata-protocol, @objectstack/objectql, @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/runtime-capabilities.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via packages/objectql, @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/metadata-protocol, @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

…t-alias-fold

# Conflicts:
#	packages/metadata-protocol/src/protocol.ts
@os-zhuang

Copy link
Copy Markdown
Contributor Author

Superseded by #4356, which landed the same fix while this was in CI — closing rather than merging, since merging would leave two implementations of one fold.

Verified theirs covers everything here, and then some: same six entry points, same #4181 conflict rule, same fold-before-withResolvedWhere ordering, same aggregate-before-the-credential-gate placement, same hook.zod.ts doc correction. Their sweep also found a live in-repo victim this one missed — auth-manager's stampIdentitySource (findOne({filter}) read the table's first row, count({filter}) counted the whole table). My grep required filter: on the call line and walked past it.

Two small deltas from this branch, recorded here rather than lost — neither worth a follow-up PR on its own, both worth knowing if that code is touched again:

  1. The conflict error carries no wire shape. foldEngineOptionAliases throws a plain Error. This branch attached status: 400 / code: 'INVALID_REQUEST', so a conflict raised inside a hook or flow body during a REST write surfaces as a 400 rather than a 500. Low impact — a conflict arriving as HTTP query params is already caught one layer up by the protocol normalizer, which does answer 400 — so it only affects in-process callers.

  2. The no-mutation invariant is documented but not pinned. foldEngineOptionAliases deliberately folds a shallow copy, with a comment explaining why ("the bag belongs to the caller and may be reused — view metadata, flow node config"). Nothing tests it: a later return bag refactor stays green while silently renaming a caller's filter key underneath it. This branch had a pin for it; the suite in engine-filter-alias.test.ts does not.

One observation on the spec side, not a defect: {canonical: 'limit', aliases: ['top']} now lives in RPC_QUERY_ALIAS_SLOTS, so RpcQueryOptionsSchema's parse transform drops top at runtime — but top is still declared on EngineQueryOptionsSchema, so it survives in the parsed type. The other five aliases are declared only on the RPC extension and so disappear from both. The result is that parsed.query.top compiles and always reads undefined, which is the exact silent-undefined footgun #3795's changeset argued against for populate. Harmless today; it would stop being harmless if anyone starts reading top off parsed RPC options.

@os-zhuang os-zhuang closed this Jul 31, 2026
@xuyushun441-sys
xuyushun441-sys deleted the claude/query-top-limit-alias-fold branch July 31, 2026 09:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P1] filter folds to where in engine.find only — findOne/count/update/delete silently match EVERY row, and the hook docs teach the broken call

1 participant