Repository navigation
[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 #4346
Description
Found while doing the #3795 follow-up sweep on the sixth alias pair (top/limit). The filter → where fold that #3795 settled at the protocol layer exists at the engine layer too — in exactly one of six methods.
ObjectQL.find() folds it, with a comment saying why:
// packages/objectql/src/engine.ts (find)
// Normalize the `filter` alias → `where`. The DataEngine contract
// (`spec/data/data-engine.zod.ts`) exposes both, but the driver AST only
// understands `where`; an internal caller passing `{ filter }` would
// otherwise match ALL rows (silent over-grant — surfaced by ADR-0057's
// sharing/graph read path). `where` wins when both are present.
if ((ast as any).filter != null && ast.where == null) { ast.where = (ast as any).filter; }
delete (ast as any).filter;findOne, count, update, delete and aggregate take the same option bags and have no such fold. The consequence the comment describes ("would otherwise match ALL rows") is therefore live in all five — and on the write methods it is not an over-grant, it is an unbounded write.
Measured, not inferred
A real ObjectQL engine + in-memory driver, three rows (a: open, b: done, c: done), each call passing only filter:
| call | result | correct |
|---|---|---|
find({filter: {status:'done'}}) |
[b, c] |
✅ the one method that folds |
findOne({filter: {status:'done'}}) |
a |
b — returned a row that does not match |
count({filter: {status:'done'}}) |
3 | 2 |
update({title:'ZAP'}, {filter:{status:'done'}, multi:true}) |
a, b, c all rewritten |
only b, c |
delete({filter:{status:'done'}, multi:true}) |
table emptied | only b, c deleted |
The write methods reach driver.updateMany(object, ast, …) / driver.deleteMany(object, ast) with ast.where === undefined, which every driver reads as "no predicate".
Why this is reachable, not theoretical
ScopedContext — the cross-object API handed to L2 hook bodies — forwards its argument verbatim:
// packages/objectql/src/engine.ts (ScopedContext)
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 documentation teaches the broken call:
// packages/spec/src/data/hook.zod.ts:403
* const users = ctx.api.object('user');
* const admin = await users.findOne({ filter: { role: 'admin' } });So a hook author following the documented example gets an arbitrary user rather than the admin — and users.delete({ filter: …, multi: true }) in a cleanup hook empties the object. options is typed any on every facade method, so nothing catches it at compile time. The deprecated DataEngine{Update,Delete,Count,Query}OptionsSchema in the spec also still declare filter as a legitimate option for these operations, so "the caller shouldn't pass it" is not a defensible position — the contract says they may.
Suggested fix
The #3795 machinery already exists and is exported: RPC_QUERY_ALIAS_SLOTS + foldQueryAliasSlots (@objectstack/spec/data). Route every engine entry point through one call to it instead of adding four more open-coded 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.
- Fold
filter→where(canonical wins; different values are a conflict, per REST 列表:无法解析的filterJSON 被静默忽略 —— 返回未过滤整页(#4134/#4164 家族第三员) #4181) infindOne,count,update,delete,aggregate, and replacefind's hand-rolled copy with the same call. - Same pass, the sixth alias pair:
top→limitis folded unguarded inprotocol.ts(options.limit = Number(options.top)— the alias overwrites the canonical key) and guarded inengine.find(top != null && limit == null— canonical wins).limitis canonical (topis described as "Alias for limit (OData compatibility)" in bothQuerySchemaandEngineQueryOptionsSchema), so the protocol has it backwards, and{top: 1, limit: 3}resolves to 1 through HTTP and 3 through a direct engine call — the [P2]protocol.tsimplements 4 of the 5 documented RPC alias precedences backwards — and disagrees withhttp-dispatcher.tson three of them #3795 divergence on the one pair its scope note excluded. - Fix the
hook.zod.tsexample towhereregardless of which way (1) lands: it is a.describe()-adjacent doc block that ships tocontent/docs/references/. - Pin each method:
{filter}alone applies the predicate;{filter, where}conflicting is refused;{top}alone still limits;{top, limit}conflicting is refused.
Note the write-path pins matter most — a regression here is silent and destructive, and the current test suite has none (this whole class went unnoticed because find was the only method anyone thought to check).
Activity
fix(objectql,spec): filter folds to where on EVERY engine method; top…
fix(sharing,runtime): a `sort` passed straight to the engine never or…
fix(objectql): a direct engine call carrying sort/select/skip/populat…
feat(objectql): engine option bags are a closed contract — unknown ke…
fix(objectql,driver-mongodb)!: findOne must say which record it wants…
feat(spec,metadata-protocol): ADR-0118 — transactions reach plugin sp…
test(metadata-core,driver-mongodb): shipped case labels and mongod ha…
test(spec): the first data/ file group's test titles state each cited…