Skip to content

Commit ec975f1

Browse files
authored
fix(objectql,driver-mongodb)!: findOne must say which record it wants, and executes every option it declares (#4419) (#4459)
findOne reads a single row, which makes its predicate the only thing between the caller and an arbitrary record. When the predicate is missing the result is not `null` — it is the object's FIRST ROW: a real, plausible-looking record with nothing to do with the request, which the `if (!row)` check every call site already has cannot catch, and which then propagates into whatever is computed next. #4419 reported this against the `filter` key, which #4346 (fold on every entry point) and #4400 (unknown keys throw) already closed. This is what those left standing. BREAKING: findOne refuses a query that selects nothing in particular. findOne(o) / findOne(o, {}) / findOne(o, { where: {} }) -> findOne(o, { where: … }) the record matching this -> findOne(o, { search: 'Acme' }) the record this search finds -> findOne(o, { orderBy: [{ field, order }] }) the FIRST in this order -> find(o, { limit: 1 }) any row will do, said aloud The error names all four. `find` and `count` are unchanged — returning or counting every row is an honest answer. The guard reads the CALLER's predicate, before RLS/sharing middleware injects its own. Two silent drops that produced the same wrong record are fixed with it: - findOne({ search }) now applies the search. The ADR-0061 expansion lived in find() alone while both methods are checked against the SAME legal-key set, so `search` passed the gate, reached a driver that does not read it, and the read ran unpredicated. - MongoDBDriver.findOne now applies orderBy, fields and offset. It translated `where` and dropped the rest, so "the newest record" returned whichever document the scan reached first. No ordering is imposed when the caller supplies none (#4363) — unchanged on both drivers. And a drift pin walks ENGINE_OPTION_KEY_SETS.findOne requiring each declared key to have an observable effect, so the next declared-but-unexecuted key fails CI instead of shipping. The Mongo cases live in one shared table read by both a server-free options suite and the real-mongod suite, so neither half can drift.
1 parent e6b1b69 commit ec975f1

13 files changed

Lines changed: 1131 additions & 48 deletions

File tree

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
---
2+
"@objectstack/objectql": major
3+
"@objectstack/spec": patch
4+
"@objectstack/driver-mongodb": patch
5+
"@objectstack/driver-sql": patch
6+
---
7+
8+
fix(objectql,driver-mongodb)!: `findOne` must say which record it wants, and executes every option it declares (#4419)
9+
10+
`findOne` reads a single row, which makes its predicate the only thing between
11+
the caller and *an arbitrary record*. When the predicate is missing the result is
12+
not `null` — it is the object's **first row**: a real, plausible-looking record
13+
with nothing to do with the request, which the `if (!row)` check every call site
14+
already has cannot catch, and which then propagates into whatever is computed
15+
next. Reported downstream: line items defaulting their price from the first
16+
product in the catalog rather than the selected one, and "is this deal already
17+
closed?" answered against an unrelated record while the write that followed
18+
correctly targeted the intended id. A throw would have been caught in
19+
development; a `null` would have been caught by the null-check. A valid-looking
20+
wrong record defeats both.
21+
22+
**Breaking — `findOne` now refuses a query that selects nothing in particular.**
23+
24+
FROM → TO:
25+
26+
| Was | Now write | Meaning |
27+
|---|---|---|
28+
| `findOne(o)`, `findOne(o, {})`, `findOne(o, { where: {} })` | `findOne(o, { where: … })` | the record matching this predicate |
29+
| | `findOne(o, { search: 'Acme' })` | the record this search finds |
30+
| | `findOne(o, { orderBy: [{ field: 'created_at', order: 'desc' }] })` | the FIRST record in this order — the newest |
31+
| | `find(o, { limit: 1 })` | any row will genuinely do, said at the call site |
32+
33+
One-line fix: add the `where` you meant, or `orderBy` if you meant "the newest
34+
one", or switch to `find(o, { limit: 1 })` if any row will do. The error names
35+
all four. `find` and `count` are unchanged — returning or counting every row is
36+
an honest answer; only `findOne`'s implicit "just one of them" turns a missing
37+
predicate into a confidently wrong record. The guard reads the CALLER's
38+
predicate, before RLS/sharing middleware injects its own: a tenant filter
39+
narrows which rows are visible, it does not make "whichever comes first"
40+
something the caller asked for.
41+
42+
**Two silent drops that produced the same wrong record are fixed with it.**
43+
44+
- **`findOne({ search })` applies the search.** The ADR-0061 `search`
45+
cross-field `$contains` expansion lived inline in `find` and nowhere else,
46+
while `find` and `findOne` are checked against the SAME legal-key set — so
47+
`search` passed the gate, rode onto the AST, and reached a driver. No driver
48+
reads `ast.search`. The read therefore ran with no predicate at all and
49+
`limit: 1` did the rest. The expansion is now one method both call.
50+
- **`MongoDBDriver.findOne` applies `orderBy`, `fields` and `offset`.** It
51+
translated `query.where` and dropped the rest, so `findOne({ orderBy })` did
52+
not return the newest record — it returned whichever document the scan reached
53+
first. `find` and `_findStream` in the same driver had always handled all
54+
three. This one matters beyond Mongo: the guard above tells an unpredicated
55+
caller to reach for `orderBy`, and an escape hatch one backend ignores is not
56+
an escape hatch. No ordering is IMPOSED when the caller supplies none — both
57+
drivers keep that carve-out (#4363), and `SqlDriver`'s comment about Mongo
58+
"never sorting" is corrected, since it cited the dropped parameter as
59+
agreement.
60+
61+
**And a gate so the class does not come back.** A drift pin walks
62+
`ENGINE_OPTION_KEY_SETS.findOne` and requires each declared key to have an
63+
observable effect — on the AST the driver receives, on the driver options, or in
64+
an explicit "not executed, and here is why" entry (only `limit`, which the
65+
contract's `limit: 1` overrides). `search` sat declared-but-unexecuted through
66+
two rounds of hardening because nothing asked that question.
67+
68+
Together with #4346 (`filter``where` folds on every entry point) and #4400
69+
(unknown option keys throw), a read parameter the engine does not execute now
70+
fails at the call site instead of quietly changing the answer.

content/docs/kernel/contracts/data-engine.mdx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ where: {
146146

147147
### findOne
148148

149-
Convenience method that returns the first record matching a query, or `null`.
149+
Returns the ONE record the query selects, or `null`.
150150

151151
```typescript
152152
const task = await engine.findOne('task', {
@@ -155,6 +155,24 @@ const task = await engine.findOne('task', {
155155
});
156156
```
157157

158+
The query must say **which** record it wants — a `where` (or a `search` that
159+
expands to one), or an `orderBy` meaning "the first record in this order". A
160+
query with neither is rejected (#4419):
161+
162+
```typescript
163+
await engine.findOne('task', {}); // throws
164+
await engine.findOne('task', { // the newest task
165+
orderBy: [{ field: 'created_at', order: 'desc' }],
166+
});
167+
await engine.find('task', { limit: 1 }); // any task will do
168+
```
169+
170+
`findOne` reads a single row, so a missing predicate does not come back as
171+
`null` — it comes back as the object's **first row**, a real record unrelated to
172+
the request that no `if (!task)` check can catch. No ordering is imposed when
173+
you supply none: `findOne` promises *a* matching record, never a position in a
174+
sequence.
175+
158176
### count
159177

160178
Returns the number of records matching a filter without fetching data.

content/docs/releases/v17.mdx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -930,6 +930,50 @@ honoured — `updateManyData`, `deleteManyData` and `batchData` persisted
930930
regardless, so a caller sending it to *preview* a mutation got it executed. It
931931
is HTTP-only; stop sending it.
932932

933+
### `findOne` must say which record it wants (#4419)
934+
935+
`findOne` reads a single row. That makes its predicate the only thing standing
936+
between the caller and *an arbitrary record* — and when the predicate is missing
937+
the result is not `null`, it is the object's **first row**: a real,
938+
plausible-looking record with nothing to do with the request, which the
939+
`if (!row)` check every call site already has cannot catch, and which then
940+
propagates into whatever is computed next. Downstream of one such call, line
941+
items defaulted their price from the first product in the catalog, and
942+
"is this deal already closed?" was answered against an unrelated record while
943+
the write that followed correctly targeted the intended id.
944+
945+
So a query that selects nothing in particular is now **refused** rather than
946+
answered. Say which record you want in one of three ways:
947+
948+
| Instead of | Write | Meaning |
949+
|---|---|---|
950+
| `findOne(o)` / `findOne(o, {})` / `findOne(o, { where: {} })` | `findOne(o, { where: … })` | the record matching this predicate |
951+
| | `findOne(o, { search: 'Acme' })` | the record this search finds |
952+
| | `findOne(o, { orderBy: [{ field: 'created_at', order: 'desc' }] })` | the FIRST record in this order — the newest |
953+
| | `find(o, { limit: 1 })` | any row will genuinely do — and the call site says so |
954+
955+
The error names all four. `find` and `count` are unchanged: returning or counting
956+
every row is an honest answer, and only `findOne`'s implicit "just one of them"
957+
turns a missing predicate into a confidently wrong record.
958+
959+
Two silent drops that produced the same wrong record are fixed with it:
960+
961+
- **`findOne({ search })` now applies the search.** The ADR-0061 `search`
962+
cross-field `$contains` expansion ran in `find` only, while both methods are
963+
checked against the same legal-key set — so `search` passed the gate, reached a
964+
driver that does not read it, and the read came back unpredicated. The
965+
expansion is now one function both call, and a drift pin requires every option
966+
`findOne` declares to have an observable effect.
967+
- **`MongoDBDriver.findOne` now applies `orderBy`, `fields` and `offset`.** It
968+
translated `where` and dropped the rest, so "the newest record" returned
969+
whichever document the scan reached first. No ordering is imposed when the
970+
caller supplies none (#4363) — that part is unchanged, on both drivers.
971+
972+
Together with the `filter``where` fold on every entry point and the
973+
unknown-key rejection (both already in this release), a read parameter the engine
974+
does not execute now fails at the call site instead of quietly changing the
975+
answer.
976+
933977
### Dead spec clusters removed
934978

935979
**App shell (2026-06 liveness audit, #4001 app step).** `App.version`,

0 commit comments

Comments
 (0)