Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions .changeset/findone-requires-a-predicate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
---
"@objectstack/objectql": major
"@objectstack/spec": patch
"@objectstack/driver-mongodb": patch
"@objectstack/driver-sql": patch
---

fix(objectql,driver-mongodb)!: `findOne` must say which record it wants, and executes every option it declares (#4419)

`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. Reported downstream: line items defaulting their price from the first
product in the catalog rather than the selected one, and "is this deal already
closed?" answered against an unrelated record while the write that followed
correctly targeted the intended id. A throw would have been caught in
development; a `null` would have been caught by the null-check. A valid-looking
wrong record defeats both.

**Breaking — `findOne` now refuses a query that selects nothing in particular.**

FROM → TO:

| Was | Now write | Meaning |
|---|---|---|
| `findOne(o)`, `findOne(o, {})`, `findOne(o, { where: {} })` | `findOne(o, { where: … })` | the record matching this predicate |
| | `findOne(o, { search: 'Acme' })` | the record this search finds |
| | `findOne(o, { orderBy: [{ field: 'created_at', order: 'desc' }] })` | the FIRST record in this order — the newest |
| | `find(o, { limit: 1 })` | any row will genuinely do, said at the call site |

One-line fix: add the `where` you meant, or `orderBy` if you meant "the newest
one", or switch to `find(o, { limit: 1 })` if any row will do. The error names
all four. `find` and `count` are unchanged — returning or counting every row is
an honest answer; only `findOne`'s implicit "just one of them" turns a missing
predicate into a confidently wrong record. The guard reads the CALLER's
predicate, before RLS/sharing middleware injects its own: a tenant filter
narrows which rows are visible, it does not make "whichever comes first"
something the caller asked for.

**Two silent drops that produced the same wrong record are fixed with it.**

- **`findOne({ search })` applies the search.** The ADR-0061 `search` →
cross-field `$contains` expansion lived inline in `find` and nowhere else,
while `find` and `findOne` are checked against the SAME legal-key set — so
`search` passed the gate, rode onto the AST, and reached a driver. No driver
reads `ast.search`. The read therefore ran with no predicate at all and
`limit: 1` did the rest. The expansion is now one method both call.
- **`MongoDBDriver.findOne` applies `orderBy`, `fields` and `offset`.** It
translated `query.where` and dropped the rest, so `findOne({ orderBy })` did
not return the newest record — it returned whichever document the scan reached
first. `find` and `_findStream` in the same driver had always handled all
three. This one matters beyond Mongo: the guard above tells an unpredicated
caller to reach for `orderBy`, and an escape hatch one backend ignores is not
an escape hatch. No ordering is IMPOSED when the caller supplies none — both
drivers keep that carve-out (#4363), and `SqlDriver`'s comment about Mongo
"never sorting" is corrected, since it cited the dropped parameter as
agreement.

**And a gate so the class does not come back.** A drift pin walks
`ENGINE_OPTION_KEY_SETS.findOne` and requires each declared key to have an
observable effect — on the AST the driver receives, on the driver options, or in
an explicit "not executed, and here is why" entry (only `limit`, which the
contract's `limit: 1` overrides). `search` sat declared-but-unexecuted through
two rounds of hardening because nothing asked that question.

Together with #4346 (`filter` → `where` folds on every entry point) and #4400
(unknown option keys throw), a read parameter the engine does not execute now
fails at the call site instead of quietly changing the answer.
20 changes: 19 additions & 1 deletion content/docs/kernel/contracts/data-engine.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ where: {

### findOne

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

```typescript
const task = await engine.findOne('task', {
Expand All @@ -155,6 +155,24 @@ const task = await engine.findOne('task', {
});
```

The query must say **which** record it wants — a `where` (or a `search` that
expands to one), or an `orderBy` meaning "the first record in this order". A
query with neither is rejected (#4419):

```typescript
await engine.findOne('task', {}); // throws
await engine.findOne('task', { // the newest task
orderBy: [{ field: 'created_at', order: 'desc' }],
});
await engine.find('task', { limit: 1 }); // any task will do
```

`findOne` reads a single row, so a missing predicate does not come back as
`null` — it comes back as the object's **first row**, a real record unrelated to
the request that no `if (!task)` check can catch. No ordering is imposed when
you supply none: `findOne` promises *a* matching record, never a position in a
sequence.

### count

Returns the number of records matching a filter without fetching data.
Expand Down
44 changes: 44 additions & 0 deletions content/docs/releases/v17.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -930,6 +930,50 @@ honoured — `updateManyData`, `deleteManyData` and `batchData` persisted
regardless, so a caller sending it to *preview* a mutation got it executed. It
is HTTP-only; stop sending it.

### `findOne` must say which record it wants (#4419)

`findOne` reads a single row. That makes its predicate the only thing standing
between the caller and *an arbitrary record* — and 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. Downstream of one such call, line
items defaulted their price from the first product in the catalog, and
"is this deal already closed?" was answered against an unrelated record while
the write that followed correctly targeted the intended id.

So a query that selects nothing in particular is now **refused** rather than
answered. Say which record you want in one of three ways:

| Instead of | Write | Meaning |
|---|---|---|
| `findOne(o)` / `findOne(o, {})` / `findOne(o, { where: {} })` | `findOne(o, { where: … })` | the record matching this predicate |
| | `findOne(o, { search: 'Acme' })` | the record this search finds |
| | `findOne(o, { orderBy: [{ field: 'created_at', order: 'desc' }] })` | the FIRST record in this order — the newest |
| | `find(o, { limit: 1 })` | any row will genuinely do — and the call site says so |

The error names all four. `find` and `count` are unchanged: returning or counting
every row is an honest answer, and only `findOne`'s implicit "just one of them"
turns a missing predicate into a confidently wrong record.

Two silent drops that produced the same wrong record are fixed with it:

- **`findOne({ search })` now applies the search.** The ADR-0061 `search` →
cross-field `$contains` expansion ran in `find` only, 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 came back unpredicated. The
expansion is now one function both call, and a drift pin requires every option
`findOne` declares to have an observable effect.
- **`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) — that part is unchanged, on both drivers.

Together with the `filter` → `where` fold on every entry point and the
unknown-key rejection (both already in this release), a read parameter the engine
does not execute now fails at the call site instead of quietly changing the
answer.

### Dead spec clusters removed

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