Skip to content

feat(flow): JSONLogic expressions, plus Filter and Wait (#2069)#2079

Merged
rubenvdlinde merged 3 commits into
developmentfrom
feat/or-flow-expressions
Jul 24, 2026
Merged

feat(flow): JSONLogic expressions, plus Filter and Wait (#2069)#2079
rubenvdlinde merged 3 commits into
developmentfrom
feat/or-flow-expressions

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Proposal: or-flow-expressions

Summary

Give flows an expression engine, and the first three nodes that need one.

Why

Expressions are how one step reads another's output — what a condition
branches on and what a field is set from. Without them a flow can only move
data, never decide anything about it.

Decision: JSONLogic, and the gap that leaves

jwadhams/json-logic-php, which openconnector already uses for
synchronisation and endpoint conditions. One expression language for the
fleet, not two. The dependency moves to OpenRegister so the engine does not
depend on a leaf app.

n8n uses a full JavaScript engine. Matching that means running user-authored
code inside the Nextcloud process — full OC\Server, database and filesystem
access from a text field in a flow editor. That trade is refused.

So JSONLogic is a ceiling by decision, and it genuinely cannot express
loops with state, parsing, or crypto. The route to those is the optional
sandboxed sidecar (#2066), never a relaxation here.

What Changes

  • FlowExpression — evaluation with a flow-shaped data document: json,
    binary, itemIndex, itemCount, context, subject.
  • Custom operators for what flows need and JSONLogic lacks: string casing,
    trim, split/join, replace, regex match; date formatting and arithmetic;
    array unique/sort/length; coalesce, toJson, fromJson. Deliberately
    small — each exists because its absence would push an author toward a Code
    node.
  • FilterNode — keeps the items whose condition holds. The simplest proof
    that an item list is a list.
  • WaitNode — the node run persistence was built for. Suspends on the way in,
    passes through on the way back.

Design decisions

  • An unevaluable condition is FALSE, not true. A branch whose condition
    could not be evaluated must not be taken.
  • Evaluation returns null rather than throwing. An author's typo should
    fail their condition, not abort a run mid-graph with side effects half
    applied. The failure belongs at save time — isValid(), called from
    validateConfig().
  • A filter re-pairs survivors to their ORIGINAL index, so provenance
    survives the drop.
  • Wait runs twice. The marking does not advance past a suspended step, so
    the node sees context.resuming on the way back and lets items through. An
    unreadable time at run time passes through rather than suspending forever.

Out of scope


Verification

88 unit tests green (21 new), phpcs clean. Covers scope (json/itemIndex/itemCount/context), falsiness of unevaluable conditions, save-time validity, every custom operator, filter provenance across a drop, matching nothing as a legitimate outcome, and Wait's suspend / resume / bare-seconds / absolute-moment paths.

Builds on #2078 (run persistence), which WaitNode depends on entirely.

🤖 Generated with Claude Code

… them

The critical path of the parity programme (#2076). Five workstreams were
waiting on this one deferral: Wait, sub-flows, run history, retry, and the
Nextcloud Flow bridge executing anything at all.

- `FlowRun` + mapper + `openregister_flow_runs`, storing the marking, the
  items, the context and the log.
- `FlowRunMarkingStore` puts the marking on the RUN rather than the subject.
  Two runs over one object hold two independent markings, which is exactly why
  Symfony's subject-property stores do not fit.
- `FlowSuspension` — a step pauses the run by throwing. An exception rather
  than a return value on purpose: a node returns ITEMS, and smuggling "please
  suspend" into that return would force every node author to know about a
  magic item shape, with a forgetful node silently continuing instead.
- The engine catches suspension BEFORE the generic Throwable handler, so a
  step's `onError` policy never sees a pause — `continue` would otherwise skip
  straight past a Wait, which is the opposite of waiting. The marking is not
  advanced, so the run resumes ON the step that asked to wait.
- `FlowRunService` — queue / execute / resume. Resuming uses the stored items,
  never a re-seed, or everything earlier steps produced would be discarded. A
  terminal run is never re-executed; retry creates a new one.
- `FlowRunWorker` — per-minute job draining the queue, waking due runs, and
  pruning terminal ones. Retention ships here rather than later: runs grow
  without bound and this instance has already been taken down once by a file
  nobody was pruning.

Only a run with a `resumeAt` is woken on a timer. One waiting on a signal — a
child run, a webhook — has none, and waking it on a clock would run it before
what it waits for arrives.

67 tests green, phpcs clean.
Expressions are how one step reads another's output. Without them a flow can
move data but never decide anything about it.

Uses `jwadhams/json-logic-php`, which openconnector already relies on for
synchronisation and endpoint conditions — moved into OpenRegister so the engine
does not depend on a leaf app. One expression language for the fleet.

An expression sees a flow-shaped document: the current item's `json` and
`binary`, its index, the step's item count, the run context and the subject. So
one authored expression applies per item, with no loop drawn by the author.

Decisions worth keeping:

- An expression that cannot be evaluated returns null, and a condition treats
  null as FALSE. A branch whose condition failed must not be taken, and
  throwing would abort a run mid-graph with side effects half applied. The
  failure belongs at save time instead — `validateConfig()` rejects it.
- A date operator given nonsense returns null, never "now". Silently
  substituting the current time would corrupt data.
- `FilterNode` re-pairs survivors to their ORIGINAL input index, so an item can
  still be traced back after the drop.
- `WaitNode` runs TWICE: the marking does not advance past a suspended step, so
  it sees `context.resuming` on the way back and passes items through. A time
  that cannot be read at run time passes through rather than suspending on a
  moment that will never arrive.

The custom operator set is deliberately small — strings, dates, arrays,
coalesce/toJson/fromJson. Each exists because its absence would push an author
toward arbitrary code, which is the gap this whole decision keeps closed. What
JSONLogic genuinely cannot do stays undone until the sandboxed sidecar (#2066).

88 tests green (21 new), phpcs clean.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openregister @ 2e4df62

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
composer ✅ 174/174
npm
PHPUnit ⏭️
Newman ⏭️
Playwright ⏭️

Quality workflow — 2026-07-24 15:13 UTC

Download the full PDF report from the workflow artifacts.

@rubenvdlinde
rubenvdlinde merged commit 5ef538f into development Jul 24, 2026
1 of 2 checks passed
@rubenvdlinde
rubenvdlinde deleted the feat/or-flow-expressions branch July 24, 2026 15:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant