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

**A declarative hook `condition` can now express a TRANSITION: the CEL scope binds `previous` alongside `record` (#4784).**

The condition gate evaluated against a single root — `{ record }`. Both published skill
docs, however, taught the `previous` form: `objectstack-formula` §5 ("Update hook
condition — `previous` vs `record`") gives
`P\`previous.status != 'escalated' && record.status == 'escalated'\``, and its legacy
migration table maps `OLD.x` → `previous.x` and `ISCHANGED(x)` → `previous.x != record.x`.
Written into a hook, any of those aborted the expression with `No such key: previous`,
which the gate swallowed into `false` — the hook simply never ran, leaving one WARN line.
Declared ≠ delivered.

It became load-bearing with #4770. `record` now means the record's **state** (stored ⊕
payload), so `record.done == true` is true on *every* update of an already-done row — not
only the one that completed it. `showcase_audit_task_completion`'s own description says
"after a task transitions to done", and there was no way to write that. Now there is:

```ts
condition: P`previous.done != true && record.done == true`
```

`previous` is built exactly as the validation side builds it (#4649), through the shared
`materializeDeclaredFields` helper, so one CEL expression means one thing on both
surfaces:

- **the stored pre-write row**, made **total over the object's DECLARED fields** — a
column the driver never returned reads as `null` instead of aborting the expression;
- **declared fields only** — `previous.dnoe` stays unevaluable, so a typo is still
reported rather than quietly answered;
- **copied, never mutated in place.** `ctx.previous` is the engine's own pre-image object,
observed by every after-hook; the materialised `null`s do not leak into it.

**Where `previous` is NOT bound** — verbatim the rule `validation/rule-validator.ts`
already applies, so referencing it there makes the condition unevaluable:

- **insert events** (`beforeInsert` / `afterInsert`) — there is no prior state. Write
insert conditions over `record` alone.
- **predicate (`multi: true`) bulk updates** — one write matches N rows and the hook fires
once, so there is no single prior record. Binding `{}` or `null` would answer
`previous.x == null` with a fabricated fact about rows nobody read.

**Cost: none.** No new demand-driven fetch was introduced. `previous` rides on the prior
row `engine.update` already reads whenever an afterUpdate hook is registered — the same
one that feeds `ctx.previous` and record-change flow triggers. A condition that never
mentions `previous` reads nothing extra, pinned by test.

**What you may see after upgrading:** hooks whose condition referenced `previous` never
fired before and start firing now. That is the declaration finally being honoured — review
any hook carrying a `previous.*` condition before you upgrade.

**Unchanged, deliberately:** a condition that is *still* unevaluable is logged at WARN and
treated as `false`. Whether that should fail loudly instead is tracked separately.
11 changes: 9 additions & 2 deletions examples/app-showcase/src/data/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,20 @@ export const NormalizeTaskTitleHook = {
description: 'Trims leading/trailing whitespace from the task title before every write.',
};

/** afterUpdate (gated) — log a line whenever a task flips to done. */
/**
* afterUpdate (gated) — log a line on the update that flips a task to done.
*
* The condition compares against `previous` on purpose (#4784). Since #4770
* `record` means the record's STATE, so `record.done == true` alone would audit
* every later edit of an already-done task — while this hook's own description
* says "transitions to done". The transition is the two-root form.
*/
export const AuditTaskCompletionHook = {
name: 'showcase_audit_task_completion',
label: 'Audit Task Completion',
object: 'showcase_task',
events: ['afterUpdate'] as LifecycleEvent[],
condition: "record.done == true",
condition: "previous.done != true && record.done == true",
body: {
language: 'js' as const,
source: "var r = ctx.result || ctx.input || {}; ctx.log.info('task completed: ' + (r.title || r.id || 'unknown'));",
Expand Down
12 changes: 12 additions & 0 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4495,6 +4495,18 @@ export class ObjectQL implements IObjectQLEngine {
// record-change flow triggers work: their start-condition gate reads
// `previous.*` (e.g. `status == "done" && previous.status != "done"`),
// which silently fails when `previous` is absent.
//
// [#4784] It is ALSO what supplies the `previous` binding to a
// declarative hook `condition` (`hook-wrappers.ts`), which is how a
// TRANSITION is expressed there: `previous.done != true &&
// record.done == true`. Note this needs NO second demand-driven
// fetch — the existing gate already fetches whenever an afterUpdate
// hook exists, and afterUpdate is the event whose context carries
// `previous`. Deliberately: adding a "does the condition reference
// `previous`?" analysis on top would be dead code today. If this
// gate is ever NARROWED (e.g. scoped per object), hook conditions
// reading `previous` must be counted into the new demand test —
// pinned by `hook-condition-previous-scope.test.ts`.
let priorRecord: Record<string, unknown> | null = null;
const updateSchema = this._registry.getObject(object);
const mediaValueShapeStrict = await this.mediaValueShapeStrictFor(updateSchema);
Expand Down
Loading
Loading