|
| 1 | +--- |
| 2 | +'@objectstack/objectql': minor |
| 3 | +--- |
| 4 | + |
| 5 | +**A declarative hook `condition` is now evaluated against the RECORD — the stored row overlaid with this write's payload — not against the update payload alone (#4770).** |
| 6 | + |
| 7 | +⚠️ **Behaviour change — read this before upgrading.** The condition gate used to evaluate |
| 8 | +against `ctx.input.data`: only the fields the current write happened to carry. |
| 9 | +`ctx.previous` sat behind it, unreachable, and the two were never merged. So a condition |
| 10 | +could reference only a field the update *happened* to touch; referencing anything else |
| 11 | +aborted the CEL expression with `No such key` — which the gate swallowed into `false`, |
| 12 | +leaving one WARN line as the sole trace. |
| 13 | + |
| 14 | +For a guard-style hook that reads as "let it through"; for an audit-style hook it reads as |
| 15 | +"do not record it". `condition: "record.done == true"` on an audit hook therefore did NOT |
| 16 | +run on the most ordinary updates there are — change the status, change the assignee — |
| 17 | +because `done` was not in the payload. |
| 18 | + |
| 19 | +The record a condition reads is now built the same way a validation predicate's is |
| 20 | +(#1871 / #4649, via one shared helper so the two cannot drift): |
| 21 | + |
| 22 | +- **stored ⊕ payload** — the prior record overlaid with this write's data, so a condition |
| 23 | + may reference any field of the record, not just the changed ones. The payload still |
| 24 | + wins for the fields it carries. |
| 25 | +- **total over the object's DECLARED fields** — `null` for a declared field present in |
| 26 | + neither, so a driver that stores only the columns it wrote no longer decides whether an |
| 27 | + expression is evaluable. |
| 28 | +- **declared fields only** — an undeclared or typo'd key (`record.stauts`) stays |
| 29 | + unevaluable and is still reported, exactly as before. |
| 30 | + |
| 31 | +Materialisation happens only when the persisted state is actually in hand — an insert, or |
| 32 | +an update whose prior row was fetched. A predicate (`multi: true`) bulk update carries no |
| 33 | +prior row, so its payload is left as it is rather than gaining `null`s that would |
| 34 | +contradict the stored rows. No code path fetches a record it did not already load. |
| 35 | + |
| 36 | +**What you may see after upgrading** |
| 37 | + |
| 38 | +- **Conditions that never fired start firing.** A hook gated on a field the payload rarely |
| 39 | + carried was silently skipped; it now evaluates. This is the declaration finally being |
| 40 | + honoured, but expect hooks to run on writes where they previously did not. |
| 41 | +- **A condition is now about the record's STATE, not about this write's diff.** |
| 42 | + `record.done == true` fires on every update of a task that *is* done, not only on the |
| 43 | + update that set it. A condition cannot express a transition today — the CEL scope binds |
| 44 | + `record` only. |
| 45 | +- **Conditions guarded with `has(...)` need `!= null`.** `has(x)` asks whether the key is |
| 46 | + **present**, and a declared field holding `null` is present — so |
| 47 | + `has(a) && has(b) && a > b` still faults on `null > null`. Same lesson as #4649: |
| 48 | + |
| 49 | + ```diff |
| 50 | + - condition: 'has(record.spent) && has(record.budget) && record.spent > record.budget' |
| 51 | + + condition: 'record.spent != null && record.budget != null && record.spent > record.budget' |
| 52 | + ``` |
| 53 | + |
| 54 | + `has()` remains correct for asking whether an **undeclared** key exists. |
| 55 | + |
| 56 | +**Unchanged, deliberately:** what happens when a condition is *still* unevaluable after |
| 57 | +merging — it is logged at WARN and treated as `false`, as before. Whether that fallback |
| 58 | +should differ by hook category (a guard fails open, an audit fails silent) is a separate |
| 59 | +decision, tracked on its own issue. |
0 commit comments