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
59 changes: 59 additions & 0 deletions .changeset/hook-condition-merged-record.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
'@objectstack/objectql': minor
---

**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).**

⚠️ **Behaviour change — read this before upgrading.** The condition gate used to evaluate
against `ctx.input.data`: only the fields the current write happened to carry.
`ctx.previous` sat behind it, unreachable, and the two were never merged. So a condition
could reference only a field the update *happened* to touch; referencing anything else
aborted the CEL expression with `No such key` — which the gate swallowed into `false`,
leaving one WARN line as the sole trace.

For a guard-style hook that reads as "let it through"; for an audit-style hook it reads as
"do not record it". `condition: "record.done == true"` on an audit hook therefore did NOT
run on the most ordinary updates there are — change the status, change the assignee —
because `done` was not in the payload.

The record a condition reads is now built the same way a validation predicate's is
(#1871 / #4649, via one shared helper so the two cannot drift):

- **stored ⊕ payload** — the prior record overlaid with this write's data, so a condition
may reference any field of the record, not just the changed ones. The payload still
wins for the fields it carries.
- **total over the object's DECLARED fields** — `null` for a declared field present in
neither, so a driver that stores only the columns it wrote no longer decides whether an
expression is evaluable.
- **declared fields only** — an undeclared or typo'd key (`record.stauts`) stays
unevaluable and is still reported, exactly as before.

Materialisation happens only when the persisted state is actually in hand — an insert, or
an update whose prior row was fetched. A predicate (`multi: true`) bulk update carries no
prior row, so its payload is left as it is rather than gaining `null`s that would
contradict the stored rows. No code path fetches a record it did not already load.

**What you may see after upgrading**

- **Conditions that never fired start firing.** A hook gated on a field the payload rarely
carried was silently skipped; it now evaluates. This is the declaration finally being
honoured, but expect hooks to run on writes where they previously did not.
- **A condition is now about the record's STATE, not about this write's diff.**
`record.done == true` fires on every update of a task that *is* done, not only on the
update that set it. A condition cannot express a transition today — the CEL scope binds
`record` only.
- **Conditions guarded with `has(...)` need `!= null`.** `has(x)` asks whether the key is
**present**, and a declared field holding `null` is present — so
`has(a) && has(b) && a > b` still faults on `null > null`. Same lesson as #4649:

```diff
- condition: 'has(record.spent) && has(record.budget) && record.spent > record.budget'
+ condition: 'record.spent != null && record.budget != null && record.spent > record.budget'
```

`has()` remains correct for asking whether an **undeclared** key exists.

**Unchanged, deliberately:** what happens when a condition is *still* unevaluable after
merging — it is logged at WARN and treated as `false`, as before. Whether that fallback
should differ by hook category (a guard fails open, an audit fails silent) is a separate
decision, tracked on its own issue.
14 changes: 9 additions & 5 deletions examples/app-showcase/src/data/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,15 @@ export const WarnOverBudgetHook = {
label: 'Warn On Over-Budget Project',
object: 'showcase_project',
events: ['afterUpdate'] as LifecycleEvent[],
// Guard with has(): an afterUpdate fired by a partial write (e.g. the
// task-rollup that only touches task_count) carries a record WITHOUT
// spent/budget, and CEL throws "No such key" on a bare `record.spent`.
// has() is the missing-key-safe macro — the hook simply skips those.
condition: "has(record.spent) && has(record.budget) && record.spent > record.budget",
// Guard with `!= null`, NOT with `has()` (#4770, same lesson as #4649). A
// condition is evaluated against the STORED record overlaid with this
// write's payload, made total over the object's declared fields — so a
// partial write (the task-rollup that only touches task_count) still sees
// spent/budget, and `has(record.spent)` is uniformly TRUE for a declared
// field, including one holding null. Only `!= null` actually keeps
// `null > null` — which CEL has no overload for — from aborting the
// expression.
condition: "record.spent != null && record.budget != null && record.spent > record.budget",
body: {
language: 'js' as const,
source: "var r = ctx.result || ctx.input || {}; ctx.log.warn('project over budget: ' + (r.name || r.id || 'unknown') + ' (' + r.spent + ' / ' + r.budget + ')');",
Expand Down
58 changes: 58 additions & 0 deletions packages/objectql/src/declared-fields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Make a record TOTAL over an object's DECLARED fields.
*
* Shared by the two places that evaluate a CEL expression against "the
* record": object-level validation predicates
* (`validation/rule-validator.ts`, #1871 / #4649) and declarative hook
* `condition`s (`hook-wrappers.ts`, #4770). They used to disagree — a
* predicate saw a total record while a hook condition saw only the fields the
* current write happened to carry — which is precisely the drift this module
* exists to prevent: an author cannot be expected to know that the same
* `record.done == true` means two different things depending on which surface
* reads it.
*
* CEL is strict about missing keys: `record.x` on a record that does not carry
* the key `x` aborts the whole expression with `No such key`, which is NOT the
* same as reading `null`. Whether a key is carried is a property of the DRIVER
* (a driver that stores only written columns returns a record missing every
* column the write never touched), not of the data — so without this an
* expression's evaluability depends on storage internals the author cannot
* see.
*
* Scope is deliberately the object's **declared fields only**. Materialising
* every key an expression happens to name would paper over author typos: a
* `record.stauts` must stay unevaluable so it is reported (fail-closed for
* validation, #4649) rather than silently read as `null` and quietly answered
* "no violation" / "condition false".
*
* `undefined` counts as absent (not just a missing key): CEL treats an own key
* holding `undefined` exactly as it treats no key at all.
*
* ## Only ever call this when the record's persisted state is IN HAND
*
* On insert there is nothing to know — absence genuinely means "no value". On
* update it is knowable only when the prior row was actually fetched. Without
* it, defaulting a declared field to `null` would not be materialising an
* absent value, it would be FABRICATING one that contradicts the stored row.
* Callers decide; this function only applies the rule.
*
* ## Consequence worth knowing before writing an expression
*
* Because a declared field is always present afterwards, `has(record.<declared
* field>)` is uniformly TRUE (a materialised `null` is a present key holding
* null — CEL's own rule). `has()` therefore guards against an UNDECLARED key,
* not against an empty value; test emptiness with `record.x != null`.
*/
export function materializeDeclaredFields<T extends Record<string, unknown>>(
record: T,
fields: Record<string, unknown> | undefined | null,
): T {
if (!fields || typeof fields !== 'object') return record;
const target = record as Record<string, unknown>;
for (const name of Object.keys(fields)) {
if (target[name] === undefined) target[name] = null;
}
return record;
}
Loading
Loading