Closes #1111.
delete(), update() and increment() built their WHERE with buildWhereClauses(), guarded by this._wheres.length > 0. Every read path uses composeWhere() — and composeWhere is the only place the soft-delete predicate is added.
So a scope expressed purely as that predicate contributed nothing to a write, and when it was the only scope the statement went out with no WHERE at all:
Post.query().onlyTrashed().delete() -> DELETE FROM sd_posts"Purge the trash" was the exact call that destroyed the live rows.
Measured on a 4-row table with 2 trashed:
| call | before | after |
|---|---|---|
onlyTrashed().delete() | total 0 — live rows gone | total 2, both live rows intact |
onlyTrashed().update({...}) | all 4 rewritten | 2 rewritten |
onlyTrashed().increment('views', 5) | all 4 bumped | 2 bumped |
query().delete() | trashed rows deleted too | trashed rows survive, as reads imply |
limit(1).delete() | every matching row deleted | throws, table intact |
The fix is the shared function, not the symptom
All three now go through composeWhere — the same function that scopes a read. Two builders answering "which rows does this query mean?" is the defect; patching each symptom would leave them free to drift apart again. This is the same shape as #1094, where two column-type mappings disagreed.
limit / offset / orderBy
None of the three read those fields, so query().where(...).orderBy('id').limit(1).delete() silently deleted every matching row instead of one. They now throw.
They cannot simply be emitted: LIMIT on an UPDATE/DELETE is not portable — Postgres has no such form, and SQLite needs a non-default compile flag. Given the choice between dropping a caller's cap on the floor and saying so, the second is the only safe one.
Deliberately unchanged
query().where(...).delete() still hard-deletes on a model declaring useSoftDeletes, while the instance-level delete() soft-deletes. I verified the asymmetry (4 rows → 2, rather than 4 with 2 marked) and left it alone: the query builder has no forceDelete(), so making this path soft-delete would leave no way to hard-delete through it at all.
That wants forceDelete() added first, and it is a behaviour change to a documented method — a separate PR, not a rider on this one. Raising it rather than picking silently.
Tests
test/orm.destructive-scope.test.ts — 11 tests, 8 fail without the src change. The three that pass both ways are the invariants: a user filter still applies, an unaffected write still works, and a model without soft deletes behaves exactly as before.
They assert row counts rather than emitted SQL, because the defect was the gap between two WHERE builders — only the resulting table settles it.
Checks
- typecheck clean;
bun test— 2031 pass, 4 skip, 1 fail (stale localbin/qbxCLI > version, pre-existing, rebuilt by CI) - pickier reports one
brace-stylewarning atorm.ts:465— pre-existing, sits at:421onmainand moved only because this PR adds lines above it
🤖 Generated with Claude Code