Conditions
Composable condition engine for enrollment guards, exit rules, and journey branching.
Overview
Hogsend ships a composable condition engine in @hogsend/core that powers three critical systems:
- Trigger guards (
trigger.where) — filter which events actually enroll a user into a journey - Exit conditions (
exitOn[].where) — determine when an active journey should terminate early - In-journey event checks (
ctx.history.hasEvent()) — query event history mid-journey for branching logic
The engine supports four condition types that can be nested arbitrarily using composite and/or logic.
Condition Types
Every condition is a discriminated union keyed on the type field:
type ConditionEval =
| PropertyCondition
| EventCondition
| EmailEngagementCondition
| CompositeCondition;Property Conditions
Check a value from the event payload or a PostHog person property against an operator.
interface PropertyCondition {
type: "property";
source: "posthog" | "context";
property: string;
operator:
| "eq"
| "neq"
| "gt"
| "gte"
| "lt"
| "lte"
| "exists"
| "not_exists"
| "contains";
value?: string | number | boolean;
}Sources:
"context"— reads from the event properties / journey context object passed at enrollment time"posthog"— reads from PostHog person properties (fetched via the PostHog plugin with Redis caching)
Example: Only enroll enterprise plan users
const trigger = {
event: "user:signed_up",
where: [
{
type: "property",
source: "context",
property: "plan",
operator: "eq",
value: "enterprise",
},
],
};Example: Check that a numeric property exceeds a threshold
{
type: "property",
source: "context",
property: "login_count",
operator: "gte",
value: 10,
}Event Conditions
Query whether a user has (or hasn't) performed an event, optionally within a time window or with a count threshold.
interface EventCondition {
type: "event";
eventName: string;
check: "exists" | "not_exists" | "count";
operator?: "gt" | "gte" | "lt" | "lte" | "eq";
value?: number;
within?: DurationObject;
}The within field accepts a DurationObject — the same format used by days(), hours(), and minutes() helpers:
interface DurationObject {
readonly hours?: number;
readonly minutes?: number;
readonly seconds?: number;
}Example: User has never completed onboarding
{
type: "event",
eventName: "onboarding:completed",
check: "not_exists",
}Example: User triggered fewer than 3 purchases in the last 7 days
{
type: "event",
eventName: "purchase:completed",
check: "count",
operator: "lt",
value: 3,
within: { hours: 168 }, // 7 days
}When check is "count", both operator and value must be provided. If either is missing, the condition falls back to a simple existence check (count > 0).
Email Engagement Conditions
Check whether a user has opened or clicked a specific email template. Looks up the most recent emailSends record for the given template.
interface EmailEngagementCondition {
type: "email_engagement";
templateKey: string;
check: "opened" | "clicked" | "not_opened" | "not_clicked";
}Example: User didn't open the welcome email
{
type: "email_engagement",
templateKey: "welcome",
check: "not_opened",
}Example: User clicked through the upgrade prompt
{
type: "email_engagement",
templateKey: "trial-expiring",
check: "clicked",
}If no matching emailSends record exists for the user + template pair, the condition returns false for all check types.
Composite Conditions
Combine any number of conditions with and or or logic. Composite conditions can nest other composites for arbitrary depth.
interface CompositeCondition {
type: "composite";
operator: "and" | "or";
conditions: ConditionEval[];
}Evaluation behavior:
"and"— short-circuit: returnsfalseon the first failing sub-condition"or"— short-circuit: returnstrueon the first passing sub-condition
Example: Enterprise user who hasn't completed setup
{
type: "composite",
operator: "and",
conditions: [
{
type: "property",
source: "context",
property: "plan",
operator: "eq",
value: "enterprise",
},
{
type: "event",
eventName: "setup:completed",
check: "not_exists",
},
],
}Example: User opened the email OR clicked any link
{
type: "composite",
operator: "or",
conditions: [
{
type: "email_engagement",
templateKey: "onboarding-guide",
check: "opened",
},
{
type: "email_engagement",
templateKey: "onboarding-guide",
check: "clicked",
},
],
}The evaluateCondition() Function
The central evaluation function lives in @hogsend/core and handles all four condition types via a type-discriminated switch:
import { evaluateCondition } from "@hogsend/core";
const result = await evaluateCondition({
condition: {
type: "property",
source: "context",
property: "plan",
operator: "eq",
value: "pro",
},
ctx: {
db, // Drizzle database instance
userId: "user_123",
journeyContext: { plan: "pro", region: "us-east" },
},
});
// result: trueThe ConditionContext provides the data needed for evaluation:
interface ConditionContext {
db: Database; // Drizzle ORM instance
userId: string; // User being evaluated
journeyContext: Record<string, unknown>; // Event properties / context data
}- Property conditions read from
journeyContext(forsource: "context") or PostHog (forsource: "posthog") - Event conditions query the
userEventstable filtered by user, event name, and optional time window - Email engagement conditions look up the latest
emailSendsrecord for the user + template - Composite conditions recursively call
evaluateCondition()for each sub-condition
Zod Schemas
Every condition type has a corresponding Zod schema for runtime validation, useful when accepting conditions from API payloads or configuration:
import {
propertyConditionSchema,
eventConditionSchema,
emailEngagementConditionSchema,
conditionEvalSchema, // discriminated union of all types (recursive via z.lazy)
} from "@hogsend/core";The conditionEvalSchema uses z.lazy() and z.discriminatedUnion() to support recursive composite nesting:
const conditionEvalSchema = z.lazy(() =>
z.discriminatedUnion("type", [
propertyConditionSchema,
eventConditionSchema,
emailEngagementConditionSchema,
z.object({
type: z.literal("composite"),
operator: z.enum(["and", "or"]),
conditions: z.array(conditionEvalSchema),
}),
]),
);Operator Reference
Property Operators
| Operator | Description | Value Required | Types |
|---|---|---|---|
eq | Strict equality (===) | Yes | string, number, boolean |
neq | Strict inequality (!==) | Yes | string, number, boolean |
gt | Greater than | Yes | number |
gte | Greater than or equal | Yes | number |
lt | Less than | Yes | number |
lte | Less than or equal | Yes | number |
exists | Value is not null or undefined | No | Any |
not_exists | Value is null or undefined | No | Any |
contains | Substring match (includes()) | Yes | string |
Event Check Modes
| Check | Description | Extra Fields |
|---|---|---|
exists | At least one matching event found | within? |
not_exists | No matching events found | within? |
count | Compare event count against a threshold | operator, value, within? |
Email Engagement Checks
| Check | Description |
|---|---|
opened | Most recent send has openedAt set |
clicked | Most recent send has clickedAt set |
not_opened | Most recent send has no openedAt |
not_clicked | Most recent send has no clickedAt |
Where Conditions Are Used
1. Trigger Guards (trigger.where)
Filter which events enroll users into a journey. All conditions in the where array must pass (implicit AND). These are PropertyCondition[] — only property checks are supported at the trigger level.
const journey = defineJourney({
meta: {
id: "enterprise-onboarding",
name: "Enterprise Onboarding",
enabled: true,
trigger: {
event: "user:signed_up",
where: [
{
type: "property",
source: "context",
property: "plan",
operator: "eq",
value: "enterprise",
},
{
type: "property",
source: "context",
property: "company_size",
operator: "gte",
value: 50,
},
],
},
entryLimit: "once",
suppress: hours(12),
},
run: async (user, ctx) => {
// Only enterprise users with 50+ employees reach here
},
});Trigger conditions are evaluated synchronously against event properties using evaluateTriggerConditions() before any database state is created. If any condition fails, the journey returns { status: "skipped", reason: "trigger_conditions_not_met" }.
2. Exit Conditions (exitOn)
Automatically terminate active journeys when specific events occur. Each exit rule has an event name and an optional where array of property conditions.
exitOn: [
// Exit unconditionally when user is deleted
{ event: "user:deleted" },
// Exit only if the payment succeeded for > $100
{
event: "payment:succeeded",
where: [
{
type: "property",
source: "context",
property: "amount",
operator: "gt",
value: 100,
},
],
},
],Exit conditions are checked by the ingestion pipeline (ingestEvent()) every time an event is stored. The pipeline queries all active/waiting journey states for the user, then evaluates each journey's exitOn rules against the incoming event. When a match is found, the journey state is set to "exited".
3. In-Journey Event Checks (ctx.history.hasEvent)
Query event history mid-journey to branch logic. This uses evaluateEventCondition() from the condition engine under the hood.
run: async (user, ctx) => {
await ctx.sleep({ duration: days(2), label: "initial-nudge" });
// Check if the user used a feature in the last 2 days
const { found: hasUsedFeature } = await ctx.history.hasEvent({
userId: user.id,
event: "feature:first_use",
within: days(2),
});
if (!hasUsedFeature) {
await sendEmail({
to: user.email,
userId: user.id,
template: "activation-nudge",
subject: "You haven't tried the key feature yet",
journeyName: user.journeyName,
});
}
},The hasEvent method returns both a boolean found flag and the raw count, so you can use either for your branching logic:
const { found, count } = await ctx.history.hasEvent({
userId: user.id,
event: "purchase:completed",
within: days(30),
});
if (count >= 5) {
// Power user path
} else if (found) {
// Has purchased, but not frequently
} else {
// Never purchased
}Evaluation Order in Journey Enrollment
When an event triggers a journey, conditions are checked in this order. The journey short-circuits at the first failure:
meta.enabled— is the journey enabled?trigger.where— do event properties pass all trigger conditions?checkEntryLimit()— has the user exceeded the entry limit (once/once_per_period/unlimited)?checkEmailPreferences()— is the user unsubscribed?- Active state check — is the user already active in this journey?
Only after all guards pass does the journey create state and begin execution.