|
| 1 | +--- |
| 2 | +"@objectstack/objectql": major |
| 3 | +--- |
| 4 | + |
| 5 | +feat(objectql)!: a hook `condition` the platform cannot evaluate now ABORTS the operation (#4775) |
| 6 | + |
| 7 | +**Breaking.** A declarative hook whose `condition` cannot be evaluated used to |
| 8 | +emit a `logger.warn` and `return false` — the hook simply did not fire. Existing |
| 9 | +hooks that have been getting by on that silent skip will now **fail the write**. |
| 10 | +That is the point of the change, not a side effect: those conditions were never |
| 11 | +enforcing anything, and the failure is how you find out. |
| 12 | + |
| 13 | +## What changed |
| 14 | + |
| 15 | +"The condition said no" and "the platform could not work out what the condition |
| 16 | +says" used to collapse into one outcome, and that one outcome carries **opposite** |
| 17 | +risks depending on the hook: |
| 18 | + |
| 19 | +- a `before*` guard ("hold this write when the condition is met") swallowed into |
| 20 | + `false` **lets through** a write it was declared to stop; |
| 21 | +- an `after*` audit ("leave a trace when the condition is met") swallowed into |
| 22 | + `false` **drops** a row nobody will go looking for, because nobody knows it |
| 23 | + should exist. |
| 24 | + |
| 25 | +So an unevaluable condition is `declared ≠ enforced`, and it is now resolved the |
| 26 | +way #4649 already resolved it for validation predicates one module over: reject |
| 27 | +loudly, naming the hook and the key that would not resolve. The rejection is a |
| 28 | +`HookConditionError` (exported), carrying `hook` / `object` / `event` / |
| 29 | +`condition` / `reason` / `fault` / `missingKey` machine-readably. |
| 30 | + |
| 31 | +`before*` and `after*` take the **same** direction, knowingly: a typo in an |
| 32 | +`afterUpdate` audit condition fails the write it was only watching. One rule, one |
| 33 | +answer — the platform does not grow a hidden second rule that makes the failure |
| 34 | +direction depend on the event name. |
| 35 | + |
| 36 | +A condition that never **compiled** aborts too. Its old treatment |
| 37 | +(`condition ignored`) was the worse half of the swallow: the gate disappeared |
| 38 | +entirely, so a declared guard let every write through and an audit fired on all |
| 39 | +of them. It is reported at invocation rather than at bind time, so one broken |
| 40 | +hook cannot wedge boot for an app nobody is writing to. |
| 41 | + |
| 42 | +## What did NOT change |
| 43 | + |
| 44 | +- A condition that evaluates **FALSE** is still just a skip, and the write still |
| 45 | + succeeds. Only *unevaluable* is new. |
| 46 | +- `onError` (`abort` / `log`) is untouched and is deliberately **not** in this |
| 47 | + path. It governs a handler that threw; the condition gate runs before the |
| 48 | + handler is ever reached. Routing a condition fault through it would let |
| 49 | + `onError: 'log'` resurrect the exact silent skip this change abolishes, and |
| 50 | + would mint a third set of semantics for one word. `retryPolicy` and `async` |
| 51 | + are outside it for the same reason. |
| 52 | + |
| 53 | +## Predicate (`multi: true`) bulk writes (#4800) |
| 54 | + |
| 55 | +A bulk write matches N rows and fires the hook **once**, so `previous` is unbound |
| 56 | +and `record` is the bare payload — there is no single prior record, and |
| 57 | +materialising declared fields to `null` would state something false about all N. |
| 58 | +Fail loud takes **no exception** here, but the message is a diagnosis rather than |
| 59 | +a riddle: it names the hook, says *this is a predicate bulk write and there is no |
| 60 | +single prior record*, and gives the route that works (rewrite without `previous`, |
| 61 | +or target the write at one record by id). |
| 62 | + |
| 63 | +It deliberately does **not** offer "use a record-change flow trigger instead": |
| 64 | +that trigger subscribes to these same lifecycle hooks, so on a bulk write it |
| 65 | +fires once with `previous` undefined too — verified against |
| 66 | +`trigger-record-change` and the engine, not assumed. Pointing at it would have |
| 67 | +made this very message the next `declared ≠ delivered`. |
| 68 | + |
| 69 | +An **undeclared** key on a bulk write still gets the ordinary typo message — that |
| 70 | +one really is a misspelling, and calling it a batch problem would send the author |
| 71 | +to fix a field that is spelled correctly. |
| 72 | + |
| 73 | +## Migrating |
| 74 | + |
| 75 | +Run your app and watch for `HookConditionError`. Each one names the hook and the |
| 76 | +key. The usual causes, in order of frequency: |
| 77 | + |
| 78 | +- **a misspelled or retired field** — fix the condition, or declare the field; |
| 79 | +- **an unguarded `null` comparison** (`record.spent > record.budget`) — guard |
| 80 | + with `!= null`. Note `has(x)` does **not** do this: a declared field holding |
| 81 | + `null` is still PRESENT, so `has(x)` is `true` and the ordering comparison |
| 82 | + still faults; |
| 83 | +- **`previous` on a bulk write** — rewrite without `previous`, or write by id; |
| 84 | +- **a bare identifier** (`done == true`) — hook conditions are `record`-scoped, |
| 85 | + so write `record.done == true`. Flow/automation conditions, which flatten |
| 86 | + fields to top level, are a different surface and are unaffected. |
0 commit comments