@hogsend/cli 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +2238 -75
- package/dist/bin.js.map +1 -1
- package/package.json +9 -1
- package/skills/hogsend-authoring-buckets/SKILL.md +69 -0
- package/skills/hogsend-authoring-buckets/references/bucket-id-aliases.md +117 -0
- package/skills/hogsend-authoring-buckets/references/bucket-meta.md +142 -0
- package/skills/hogsend-authoring-buckets/references/buckets-vs-journeys.md +96 -0
- package/skills/hogsend-authoring-buckets/references/register-a-bucket.md +129 -0
- package/skills/hogsend-authoring-emails/SKILL.md +68 -0
- package/skills/hogsend-authoring-emails/references/email-components.md +112 -0
- package/skills/hogsend-authoring-emails/references/preview-and-render.md +116 -0
- package/skills/hogsend-authoring-emails/references/template-four-file-contract.md +134 -0
- package/skills/hogsend-authoring-emails/references/tracking-and-unsubscribe.md +127 -0
- package/skills/hogsend-authoring-journeys/SKILL.md +88 -0
- package/skills/hogsend-authoring-journeys/references/branch-on-engagement.md +93 -0
- package/skills/hogsend-authoring-journeys/references/journey-context.md +110 -0
- package/skills/hogsend-authoring-journeys/references/journey-meta.md +142 -0
- package/skills/hogsend-authoring-journeys/references/register-a-journey.md +99 -0
- package/skills/hogsend-authoring-journeys/references/sending-email-from-a-journey.md +82 -0
- package/skills/hogsend-cli/SKILL.md +81 -0
- package/skills/hogsend-cli/references/debug-a-journey.md +66 -0
- package/skills/hogsend-cli/references/manage-journeys.md +53 -0
- package/skills/hogsend-cli/references/query-stats.md +66 -0
- package/skills/hogsend-cli/references/setup-local.md +52 -0
- package/skills/hogsend-conditions/SKILL.md +70 -0
- package/skills/hogsend-conditions/references/condition-types.md +251 -0
- package/skills/hogsend-conditions/references/durations.md +90 -0
- package/skills/hogsend-conditions/references/examples.md +188 -0
- package/skills/hogsend-database/SKILL.md +70 -0
- package/skills/hogsend-database/references/client-track-schema.md +97 -0
- package/skills/hogsend-database/references/migrations.md +132 -0
- package/skills/hogsend-database/references/schema-drift.md +123 -0
- package/skills/hogsend-deploy/SKILL.md +62 -0
- package/skills/hogsend-deploy/references/env-and-secrets.md +118 -0
- package/skills/hogsend-deploy/references/railway-two-services.md +122 -0
- package/skills/hogsend-deploy/references/upgrade-engine.md +92 -0
- package/skills/hogsend-webhooks-and-workflows/SKILL.md +68 -0
- package/skills/hogsend-webhooks-and-workflows/references/backfill-pattern.md +148 -0
- package/skills/hogsend-webhooks-and-workflows/references/custom-workflow.md +156 -0
- package/skills/hogsend-webhooks-and-workflows/references/webhook-source.md +172 -0
- package/src/bin.ts +73 -111
- package/src/commands/contacts.ts +316 -0
- package/src/commands/doctor.ts +239 -0
- package/src/commands/eject.ts +106 -0
- package/src/commands/events.ts +154 -0
- package/src/commands/index.ts +36 -0
- package/src/commands/journeys.ts +343 -0
- package/src/commands/patch.ts +80 -0
- package/src/commands/setup.ts +322 -0
- package/src/commands/skills.ts +208 -0
- package/src/commands/stats.ts +87 -0
- package/src/commands/studio.ts +261 -0
- package/src/commands/types.ts +41 -0
- package/src/commands/upgrade.ts +245 -0
- package/src/index.ts +2 -0
- package/src/lib/config.ts +147 -0
- package/src/lib/http.ts +145 -0
- package/src/lib/output.ts +185 -0
- package/src/lib/prompt.ts +17 -0
- package/src/lib/skills.ts +186 -0
- package/studio/assets/index-BVA9GZqq.css +1 -0
- package/studio/assets/index-kPwzOOyG.js +230 -0
- package/studio/index.html +13 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: hogsend-conditions
|
|
3
|
+
description: Use when writing any condition or duration in a Hogsend app — a journey trigger.where, an exitOn rule, or a bucket criteria tree. Covers the four condition types (property, event with a time window + count, email_engagement by template, composite and/or) and DurationObjects via days()/hours()/minutes() instead of magic duration strings.
|
|
4
|
+
license: MIT
|
|
5
|
+
metadata:
|
|
6
|
+
author: withSeismic
|
|
7
|
+
version: "1.0.0"
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Hogsend conditions & durations
|
|
11
|
+
|
|
12
|
+
Hogsend has ONE condition engine, shared everywhere you express "does this
|
|
13
|
+
user match?" — a journey's `trigger.where`, an `exitOn` rule, and a bucket's
|
|
14
|
+
`criteria` tree all speak the same vocabulary. This skill is the single source
|
|
15
|
+
of truth for that vocabulary and for the `DurationObject` helpers (`days()`,
|
|
16
|
+
`hours()`, `minutes()`) that replace magic duration strings. The journeys and
|
|
17
|
+
buckets skills link here for the condition/duration details.
|
|
18
|
+
|
|
19
|
+
Everything is plain data: a condition is a discriminated-union POJO
|
|
20
|
+
(`ConditionEval`), a duration is `{ hours?, minutes?, seconds? }`. You author
|
|
21
|
+
them as data or via the fluent `criteriaBuilder` (buckets only) — both produce
|
|
22
|
+
byte-identical POJOs. You import these from `@hogsend/core` (re-exported by
|
|
23
|
+
`@hogsend/engine`); you never edit the engine.
|
|
24
|
+
|
|
25
|
+
## The four condition types (`ConditionEval`)
|
|
26
|
+
|
|
27
|
+
| `type` | Matches on | Key fields |
|
|
28
|
+
|--------|-----------|------------|
|
|
29
|
+
| `"property"` | a person/event property value | `property`, `operator`, `value?` |
|
|
30
|
+
| `"event"` | event occurrences, optionally in a rolling window, optionally counted | `eventName`, `check`, `operator?`, `value?`, `within?` |
|
|
31
|
+
| `"email_engagement"` | open/click state of the last send of a template | `templateKey`, `check` |
|
|
32
|
+
| `"composite"` | AND / OR over child conditions | `operator` (`"and"`/`"or"`), `conditions[]` |
|
|
33
|
+
|
|
34
|
+
## Where each surface accepts what (IMPORTANT)
|
|
35
|
+
|
|
36
|
+
Not every surface accepts all four types — match the type to the field:
|
|
37
|
+
|
|
38
|
+
- **`trigger.where`** (journey) → `PropertyCondition[]` ONLY. Property
|
|
39
|
+
conditions, AND-ed together, evaluated against the triggering event's
|
|
40
|
+
properties. No event/engagement/composite legs here.
|
|
41
|
+
- **`exitOn[].where`** (journey) → `PropertyCondition[]` ONLY. Same shape;
|
|
42
|
+
AND-ed against the incoming event's properties. Omit `where` to exit on the
|
|
43
|
+
event name alone.
|
|
44
|
+
- **`criteria`** (bucket) → a single `ConditionEval` tree — ALL FOUR types,
|
|
45
|
+
composed with `composite` / `b.all()` / `b.any()`. This is the only surface
|
|
46
|
+
that runs against the database (event counts, engagement, windows).
|
|
47
|
+
|
|
48
|
+
## Task playbooks — load the matching reference
|
|
49
|
+
|
|
50
|
+
- **Exact shapes, every operator, the `within` window + count, and how
|
|
51
|
+
`evaluateCondition` reads them** → load `references/condition-types.md`
|
|
52
|
+
- **`days()` / `hours()` / `minutes()` `DurationObject`s and where durations
|
|
53
|
+
are valid (sleep, `within`, `entryPeriod`, dwell)** → load
|
|
54
|
+
`references/durations.md`
|
|
55
|
+
- **Copy-paste `trigger.where`, `exitOn`, and bucket `criteria` built from the
|
|
56
|
+
real builder + types** → load `references/examples.md`
|
|
57
|
+
|
|
58
|
+
## Golden rules
|
|
59
|
+
|
|
60
|
+
1. `trigger.where` and `exitOn[].where` take `PropertyCondition[]` only — if
|
|
61
|
+
you need an event count or a time window, that logic belongs in the
|
|
62
|
+
journey's `run` body (`ctx.history.hasEvent`) or in a bucket's `criteria`,
|
|
63
|
+
not in `where`.
|
|
64
|
+
2. Use the duration helpers — never hand-write `{ hours: 168 }`; write
|
|
65
|
+
`days(7)`.
|
|
66
|
+
3. A dynamic bucket's `criteria` MUST contain at least one positive condition;
|
|
67
|
+
a pure-negation tree is rejected at registration.
|
|
68
|
+
4. To author/run a journey or bucket end to end, see the
|
|
69
|
+
hogsend-authoring-journeys and hogsend-authoring-buckets skills; to verify a
|
|
70
|
+
running instance, see the hogsend-cli skill.
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# Condition types — exact shapes & operators
|
|
2
|
+
|
|
3
|
+
The condition system is the `ConditionEval` discriminated union from
|
|
4
|
+
`@hogsend/core` (re-exported by `@hogsend/engine`). Every condition is a plain
|
|
5
|
+
POJO discriminated by `type`. This is the canonical reference for the shapes,
|
|
6
|
+
operators, and how the engine evaluates each.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import type {
|
|
10
|
+
ConditionEval,
|
|
11
|
+
PropertyCondition,
|
|
12
|
+
EventCondition,
|
|
13
|
+
EmailEngagementCondition,
|
|
14
|
+
CompositeCondition,
|
|
15
|
+
} from "@hogsend/core/types";
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
type ConditionEval =
|
|
20
|
+
| PropertyCondition
|
|
21
|
+
| EventCondition
|
|
22
|
+
| EmailEngagementCondition
|
|
23
|
+
| CompositeCondition;
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## 1. `property` — `PropertyCondition`
|
|
29
|
+
|
|
30
|
+
Compares a single property value against an expected `value`.
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
interface PropertyCondition {
|
|
34
|
+
type: "property";
|
|
35
|
+
property: string;
|
|
36
|
+
operator:
|
|
37
|
+
| "eq"
|
|
38
|
+
| "neq"
|
|
39
|
+
| "gt"
|
|
40
|
+
| "gte"
|
|
41
|
+
| "lt"
|
|
42
|
+
| "lte"
|
|
43
|
+
| "exists"
|
|
44
|
+
| "not_exists"
|
|
45
|
+
| "contains";
|
|
46
|
+
value?: string | number | boolean;
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Operator semantics (from `conditions/property.ts`):
|
|
51
|
+
|
|
52
|
+
| operator | meaning | value type | notes |
|
|
53
|
+
|----------|---------|------------|-------|
|
|
54
|
+
| `eq` | `actual === value` | string \| number \| boolean | strict equality |
|
|
55
|
+
| `neq` | `actual !== value` | string \| number \| boolean | |
|
|
56
|
+
| `gt` / `gte` / `lt` / `lte` | numeric comparison | number | both sides must be `number`, else `false` |
|
|
57
|
+
| `exists` | value is not `undefined`/`null` | omit `value` | |
|
|
58
|
+
| `not_exists` | value is `undefined`/`null` | omit `value` | |
|
|
59
|
+
| `contains` | `actual.includes(value)` | string | both sides must be `string`, else `false` |
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
// Examples (declarative POJOs)
|
|
63
|
+
{ type: "property", property: "plan", operator: "eq", value: "pro" }
|
|
64
|
+
{ type: "property", property: "seats", operator: "gte", value: 5 }
|
|
65
|
+
{ type: "property", property: "company", operator: "exists" }
|
|
66
|
+
{ type: "property", property: "email", operator: "contains", value: "@acme.com" }
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Used directly by `trigger.where` and `exitOn[].where` (both are
|
|
70
|
+
`PropertyCondition[]`, AND-ed via `evaluatePropertyConditions` — see
|
|
71
|
+
`references/examples.md`).
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## 2. `event` — `EventCondition`
|
|
76
|
+
|
|
77
|
+
Counts occurrences of `eventName`, optionally inside a rolling `within` window,
|
|
78
|
+
and applies a `check`. This is the only type with a time window.
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import type { DurationObject } from "@hogsend/core";
|
|
82
|
+
|
|
83
|
+
interface EventCondition {
|
|
84
|
+
type: "event";
|
|
85
|
+
eventName: string;
|
|
86
|
+
check: "exists" | "not_exists" | "count";
|
|
87
|
+
operator?: "gt" | "gte" | "lt" | "lte" | "eq"; // only with check:"count"
|
|
88
|
+
value?: number; // only with check:"count"
|
|
89
|
+
within?: DurationObject; // rolling window, e.g. days(7)
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
How it evaluates (`conditions/event.ts`): it counts rows in `userEvents` for
|
|
94
|
+
the user matching `eventName`, restricted to `occurredAt >= now - within` when
|
|
95
|
+
`within` is set, then:
|
|
96
|
+
|
|
97
|
+
| `check` | matches when |
|
|
98
|
+
|---------|--------------|
|
|
99
|
+
| `exists` | `count > 0` |
|
|
100
|
+
| `not_exists` | `count === 0` |
|
|
101
|
+
| `count` + `operator`/`value` | `count <op> value` (`gt`/`gte`/`lt`/`lte`/`eq`) |
|
|
102
|
+
| `count` with no `operator`/`value` | falls back to `count > 0` |
|
|
103
|
+
|
|
104
|
+
`within` makes the condition time-based: in a bucket, a windowed event leg is
|
|
105
|
+
what the reconcile cron sweeps. See the hogsend-authoring-buckets skill for the
|
|
106
|
+
reconcile implications.
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
// Declarative
|
|
110
|
+
{ type: "event", eventName: "app.active", check: "exists" }
|
|
111
|
+
{ type: "event", eventName: "app.active", check: "not_exists", within: days(7) }
|
|
112
|
+
{ type: "event", eventName: "purchase", check: "count", operator: "gte", value: 3 }
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## 3. `email_engagement` — `EmailEngagementCondition`
|
|
118
|
+
|
|
119
|
+
Checks the open/click state of the MOST RECENT send of a given template to the
|
|
120
|
+
user.
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
interface EmailEngagementCondition {
|
|
124
|
+
type: "email_engagement";
|
|
125
|
+
templateKey: string;
|
|
126
|
+
check: "opened" | "clicked" | "not_opened" | "not_clicked";
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
How it evaluates (`conditions/email-engagement.ts`): finds the latest
|
|
131
|
+
`emailSends` row for the user + `templateKey`. If there is NO send, every check
|
|
132
|
+
returns `false`. Otherwise:
|
|
133
|
+
|
|
134
|
+
| `check` | matches when |
|
|
135
|
+
|---------|--------------|
|
|
136
|
+
| `opened` | `openedAt` is set |
|
|
137
|
+
| `clicked` | `clickedAt` is set |
|
|
138
|
+
| `not_opened` | `openedAt` is null |
|
|
139
|
+
| `not_clicked` | `clickedAt` is null |
|
|
140
|
+
|
|
141
|
+
`templateKey` is a key from your `src/emails/` template registry (the same
|
|
142
|
+
values you use in `Templates`).
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
{ type: "email_engagement", templateKey: "welcome", check: "not_opened" }
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## 4. `composite` — `CompositeCondition`
|
|
151
|
+
|
|
152
|
+
AND / OR over child `ConditionEval`s. Children can themselves be composites
|
|
153
|
+
(nest freely).
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
interface CompositeCondition {
|
|
157
|
+
type: "composite";
|
|
158
|
+
operator: "and" | "or";
|
|
159
|
+
conditions: ConditionEval[];
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Short-circuit semantics (`conditions/composite.ts`): `and` returns `false` on
|
|
164
|
+
the first child that fails; `or` returns `true` on the first child that passes.
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
{
|
|
168
|
+
type: "composite",
|
|
169
|
+
operator: "and",
|
|
170
|
+
conditions: [
|
|
171
|
+
{ type: "property", property: "plan", operator: "eq", value: "trial" },
|
|
172
|
+
{ type: "event", eventName: "app.active", check: "not_exists", within: days(7) },
|
|
173
|
+
],
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## The fluent builder (bucket `criteria` only)
|
|
180
|
+
|
|
181
|
+
`defineBucket`'s `criteria` accepts either a `ConditionEval` POJO OR a function
|
|
182
|
+
`(b) => ConditionEval`, where `b` is the `CriteriaBuilder`. The builder runs
|
|
183
|
+
ONCE at definition time and returns the same POJO — it never executes per user.
|
|
184
|
+
You can also import `criteriaBuilder` standalone to compose reusable fragments.
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
import { criteriaBuilder } from "@hogsend/core";
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
interface CriteriaBuilder {
|
|
192
|
+
prop(property: string): PropertyMatcher;
|
|
193
|
+
event(eventName: string): EventMatcher;
|
|
194
|
+
all(...conditions: ConditionEval[]): CompositeCondition; // composite "and"
|
|
195
|
+
any(...conditions: ConditionEval[]): CompositeCondition; // composite "or"
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`PropertyMatcher` terminals → `PropertyCondition`:
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
b.prop("plan").eq("pro") // operator "eq"
|
|
203
|
+
b.prop("plan").neq("free") // "neq"
|
|
204
|
+
b.prop("seats").gt(5) // "gt"
|
|
205
|
+
b.prop("seats").gte(5) // "gte"
|
|
206
|
+
b.prop("seats").lt(10) // "lt"
|
|
207
|
+
b.prop("seats").lte(10) // "lte"
|
|
208
|
+
b.prop("email").contains("@acme") // "contains"
|
|
209
|
+
b.prop("company").exists() // "exists"
|
|
210
|
+
b.prop("company").notExists() // "not_exists"
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
`EventMatcher` — optional `.within(window)` precedes the terminal; terminals →
|
|
214
|
+
`EventCondition`:
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
b.event("app.active").exists() // check "exists"
|
|
218
|
+
b.event("app.active").within(days(7)).notExists() // check "not_exists" + window
|
|
219
|
+
b.event("purchase").count("gte", 3) // check "count", operator "gte"
|
|
220
|
+
b.event("purchase").atLeast(3) // count gte 3
|
|
221
|
+
b.event("purchase").moreThan(3) // count gt 3
|
|
222
|
+
b.event("purchase").atMost(3) // count lte 3
|
|
223
|
+
b.event("purchase").lessThan(3) // count lt 3
|
|
224
|
+
b.event("purchase").exactly(3) // count eq 3
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
There is no builder terminal for `email_engagement` — author those as POJOs
|
|
228
|
+
inside `b.all(...)` / `b.any(...)` when you need them.
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
## How conditions are evaluated
|
|
233
|
+
|
|
234
|
+
`evaluateCondition({ condition, ctx })` (`conditions/evaluate.ts`) is the engine
|
|
235
|
+
entry point; it dispatches on `condition.type`. The `ctx` is a
|
|
236
|
+
`ConditionContext`:
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
interface ConditionContext {
|
|
240
|
+
db: Database; // event/engagement legs query this
|
|
241
|
+
userId: string;
|
|
242
|
+
journeyContext: Record<string, unknown>; // property legs read from here
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
You normally do NOT call `evaluateCondition` yourself — the engine runs it for
|
|
247
|
+
bucket `criteria`. For `trigger.where` / `exitOn[].where`, the engine uses
|
|
248
|
+
`evaluatePropertyConditions({ conditions, properties })`, which AND-s a
|
|
249
|
+
`PropertyCondition[]` against an event's properties (`.every(...)`). Knowing
|
|
250
|
+
which evaluator runs tells you which condition types a surface accepts (see
|
|
251
|
+
`references/examples.md`).
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Durations — `DurationObject` & the helpers
|
|
2
|
+
|
|
3
|
+
Every "how long" in Hogsend is a `DurationObject`, never a magic string. You
|
|
4
|
+
build them with `days()`, `hours()`, `minutes()` from `@hogsend/core`
|
|
5
|
+
(re-exported by `@hogsend/engine`).
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { days, hours, minutes } from "@hogsend/core";
|
|
9
|
+
// or, alongside engine factories:
|
|
10
|
+
import { days, hours, minutes } from "@hogsend/engine";
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## The shape
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
interface DurationObject {
|
|
17
|
+
readonly hours?: number;
|
|
18
|
+
readonly minutes?: number;
|
|
19
|
+
readonly seconds?: number;
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## The helpers (exact definitions)
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
days(n) // => { hours: n * 24 } — yes, days are expressed as hours
|
|
27
|
+
hours(n) // => { hours: n }
|
|
28
|
+
minutes(n) // => { minutes: n }
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
So `days(7)` is `{ hours: 168 }`. There is no `days` field on `DurationObject`
|
|
32
|
+
— `days()` normalizes to `hours`. Don't hand-write the object; the helpers keep
|
|
33
|
+
intent readable and are what the codebase uses everywhere.
|
|
34
|
+
|
|
35
|
+
`durationToMs(d)` is the conversion used internally
|
|
36
|
+
(`hours*3_600_000 + minutes*60_000 + seconds*1_000`). You rarely call it
|
|
37
|
+
directly; it backs `ctx.sleep` and the `within` window math.
|
|
38
|
+
|
|
39
|
+
## Where durations are valid
|
|
40
|
+
|
|
41
|
+
A `DurationObject` is accepted anywhere the engine measures elapsed/remaining
|
|
42
|
+
time. The main spots a consumer touches:
|
|
43
|
+
|
|
44
|
+
| Location | Field | What it means |
|
|
45
|
+
|----------|-------|---------------|
|
|
46
|
+
| Journey meta | `suppress` | global cool-off after this journey runs |
|
|
47
|
+
| Journey meta | `entryPeriod` | window for `entryLimit: "once_per_period"` |
|
|
48
|
+
| Journey `run` | `ctx.sleep({ duration })` | durable wait inside a journey |
|
|
49
|
+
| Journey `run` | `ctx.history.hasEvent({ within })` | look-back window for an event check |
|
|
50
|
+
| Condition | `EventCondition.within` | rolling window on an `event` condition |
|
|
51
|
+
| Bucket meta | `entryPeriod` | window for the bucket's `entryLimit` |
|
|
52
|
+
| Bucket meta | `minDwell` / `maxDwell` | membership debounce floor / unconditional TTL |
|
|
53
|
+
| Bucket meta | `reconcileEvery` | advisory reconcile cadence (Studio display) |
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
// Journey meta
|
|
57
|
+
meta: {
|
|
58
|
+
// ...
|
|
59
|
+
entryLimit: "once_per_period",
|
|
60
|
+
entryPeriod: days(3),
|
|
61
|
+
suppress: hours(4),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Inside run()
|
|
65
|
+
await ctx.sleep({ duration: hours(2), label: "initial-followup" });
|
|
66
|
+
const { found } = await ctx.history.hasEvent({
|
|
67
|
+
userId: user.id,
|
|
68
|
+
event: Events.CHECKOUT_COMPLETED,
|
|
69
|
+
within: hours(26),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// On an event condition (bucket criteria)
|
|
73
|
+
{ type: "event", eventName: "app.active", check: "not_exists", within: days(7) }
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Composing values
|
|
77
|
+
|
|
78
|
+
The helpers each return a single-field object, so to express "90 minutes" use
|
|
79
|
+
`minutes(90)`, not `hours(1)` plus `minutes(30)` — there's no add helper. Pick
|
|
80
|
+
the largest single unit that reads cleanly:
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
minutes(90) // 90 minutes
|
|
84
|
+
hours(36) // a day and a half
|
|
85
|
+
days(14) // two weeks
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
For the surfaces that consume these (journey orchestration, bucket dwell,
|
|
89
|
+
condition windows), see the hogsend-authoring-journeys and
|
|
90
|
+
hogsend-authoring-buckets skills.
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# Copy-paste examples — `trigger.where`, `exitOn`, bucket `criteria`
|
|
2
|
+
|
|
3
|
+
Real, type-checked patterns built from the actual types and builder. Drop these
|
|
4
|
+
into `src/journeys/*.ts` and `src/buckets/*.ts`. Imports come from
|
|
5
|
+
`@hogsend/engine` / `@hogsend/core`; you never edit the engine.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## `trigger.where` — gate enrollment on event properties
|
|
10
|
+
|
|
11
|
+
`trigger.where` is a `PropertyCondition[]`. The conditions are AND-ed together
|
|
12
|
+
(`evaluatePropertyConditions` → `.every(...)`) against the TRIGGERING event's
|
|
13
|
+
properties. Only `property` conditions are valid here — there is no `within`,
|
|
14
|
+
no event count, no composite. (Need a count or a window? Put that logic in the
|
|
15
|
+
`run` body via `ctx.history.hasEvent`, or model it as a bucket.)
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { days, hours } from "@hogsend/core";
|
|
19
|
+
import { defineJourney, sendEmail } from "@hogsend/engine";
|
|
20
|
+
import { Events, Templates } from "./constants/index.js";
|
|
21
|
+
|
|
22
|
+
export const proCheckoutAbandoned = defineJourney({
|
|
23
|
+
meta: {
|
|
24
|
+
id: "pro-checkout-abandoned",
|
|
25
|
+
name: "Pro plan — checkout abandoned",
|
|
26
|
+
enabled: true,
|
|
27
|
+
trigger: {
|
|
28
|
+
event: Events.CHECKOUT_ABANDONED,
|
|
29
|
+
// only fire for high-value abandons
|
|
30
|
+
where: [
|
|
31
|
+
{ type: "property", property: "plan", operator: "eq", value: "pro" },
|
|
32
|
+
{ type: "property", property: "cartValue", operator: "gte", value: 100 },
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
entryLimit: "once_per_period",
|
|
36
|
+
entryPeriod: days(3),
|
|
37
|
+
suppress: hours(4),
|
|
38
|
+
},
|
|
39
|
+
run: async (user, ctx) => {
|
|
40
|
+
await ctx.sleep({ duration: hours(2), label: "nudge" });
|
|
41
|
+
await sendEmail({
|
|
42
|
+
to: user.email,
|
|
43
|
+
userId: user.id,
|
|
44
|
+
journeyStateId: user.stateId,
|
|
45
|
+
template: Templates.CONVERSION_WINBACK_OFFER,
|
|
46
|
+
subject: "Still thinking it over?",
|
|
47
|
+
journeyName: user.journeyName,
|
|
48
|
+
});
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## `exitOn` — leave the journey when a later event arrives
|
|
56
|
+
|
|
57
|
+
`exitOn` is an array of `{ event, where? }`. The engine matches the incoming
|
|
58
|
+
event name; if `where` is present it must pass (`PropertyCondition[]`, AND-ed)
|
|
59
|
+
for the exit to fire. Omit `where` to exit on the event name alone.
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
meta: {
|
|
63
|
+
// ...
|
|
64
|
+
exitOn: [
|
|
65
|
+
// exit on any completed checkout
|
|
66
|
+
{ event: Events.CHECKOUT_COMPLETED },
|
|
67
|
+
// exit on a subscription, but only if it's the pro plan
|
|
68
|
+
{
|
|
69
|
+
event: Events.SUBSCRIPTION_CREATED,
|
|
70
|
+
where: [
|
|
71
|
+
{ type: "property", property: "plan", operator: "eq", value: "pro" },
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Bucket `criteria` — the full condition engine
|
|
81
|
+
|
|
82
|
+
`criteria` is a single `ConditionEval` tree and accepts ALL FOUR types. Author
|
|
83
|
+
it with the fluent builder `(b) => ...` (preferred) or as a declarative POJO.
|
|
84
|
+
Both forms produce identical data. A dynamic bucket MUST have at least one
|
|
85
|
+
POSITIVE condition (a pure-negation tree is rejected at registration).
|
|
86
|
+
|
|
87
|
+
### Builder form (recommended)
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import { days, defineBucket } from "@hogsend/engine";
|
|
91
|
+
import { Events } from "../journeys/constants/index.js";
|
|
92
|
+
|
|
93
|
+
// Lapsed-active: was active once, but NOT in the last 7 days.
|
|
94
|
+
export const wentDormant = defineBucket({
|
|
95
|
+
meta: {
|
|
96
|
+
id: "went-dormant",
|
|
97
|
+
name: "Went dormant",
|
|
98
|
+
enabled: true,
|
|
99
|
+
timeBased: true,
|
|
100
|
+
fastExpiry: true,
|
|
101
|
+
criteria: (b) =>
|
|
102
|
+
b.all(
|
|
103
|
+
b.event(Events.APP_ACTIVE).exists(), // positive anchor
|
|
104
|
+
b.event(Events.APP_ACTIVE).within(days(7)).notExists(), // windowed absence
|
|
105
|
+
),
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Mixing all four types
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import { days, defineBucket } from "@hogsend/engine";
|
|
114
|
+
|
|
115
|
+
export const atRiskPro = defineBucket({
|
|
116
|
+
meta: {
|
|
117
|
+
id: "at-risk-pro",
|
|
118
|
+
name: "At-risk pro accounts",
|
|
119
|
+
enabled: true,
|
|
120
|
+
timeBased: true,
|
|
121
|
+
criteria: (b) =>
|
|
122
|
+
b.all(
|
|
123
|
+
b.prop("plan").eq("pro"), // property
|
|
124
|
+
b.event("app.active").within(days(14)).lessThan(3), // event count + window
|
|
125
|
+
b.any(
|
|
126
|
+
b.event("support.ticket").within(days(30)).exists(),
|
|
127
|
+
// email_engagement has no builder terminal — drop the POJO in directly:
|
|
128
|
+
{ type: "email_engagement", templateKey: "renewal-reminder", check: "not_opened" },
|
|
129
|
+
),
|
|
130
|
+
),
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Declarative form (identical result)
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
export const wentDormantDeclarative = defineBucket({
|
|
139
|
+
meta: {
|
|
140
|
+
id: "went-dormant",
|
|
141
|
+
name: "Went dormant",
|
|
142
|
+
enabled: true,
|
|
143
|
+
timeBased: true,
|
|
144
|
+
criteria: {
|
|
145
|
+
type: "composite",
|
|
146
|
+
operator: "and",
|
|
147
|
+
conditions: [
|
|
148
|
+
{ type: "event", eventName: "app.active", check: "exists" },
|
|
149
|
+
{ type: "event", eventName: "app.active", check: "not_exists", within: days(7) },
|
|
150
|
+
],
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Reusable fragments via `criteriaBuilder`
|
|
159
|
+
|
|
160
|
+
Import the builder standalone to compose shared criteria pieces (handy in tests
|
|
161
|
+
or to DRY up several buckets):
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
import { criteriaBuilder as b, type ConditionEval } from "@hogsend/core";
|
|
165
|
+
|
|
166
|
+
const isPro: ConditionEval = b.prop("plan").eq("pro");
|
|
167
|
+
const wentQuiet = (window: ReturnType<typeof days>): ConditionEval =>
|
|
168
|
+
b.event("app.active").within(window).notExists();
|
|
169
|
+
|
|
170
|
+
// use inside a bucket
|
|
171
|
+
criteria: (c) => c.all(isPro, wentQuiet(days(7))),
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## Quick decision guide
|
|
177
|
+
|
|
178
|
+
- Gating who ENTERS a journey on event properties → `trigger.where`
|
|
179
|
+
(`PropertyCondition[]`).
|
|
180
|
+
- Pulling someone OUT of a journey when a later event lands → `exitOn`
|
|
181
|
+
(`{ event, where? }`, `where` is `PropertyCondition[]`).
|
|
182
|
+
- "Is the user in this segment right now?" with counts / windows / engagement →
|
|
183
|
+
a bucket `criteria` tree (full `ConditionEval`). See the
|
|
184
|
+
hogsend-authoring-buckets skill.
|
|
185
|
+
- A look-back check mid-journey (count or window) → `ctx.history.hasEvent` in
|
|
186
|
+
the `run` body. See the hogsend-authoring-journeys skill.
|
|
187
|
+
- Confirm a condition is actually firing on a running instance → see the
|
|
188
|
+
hogsend-cli skill.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: hogsend-database
|
|
3
|
+
description: Use when changing the database schema or running migrations in a Hogsend app — adding your own (client-track) tables in src/schema/ with Drizzle pgTable then db:generate + db:migrate, understanding the two-track system (engine-owned tables in @hogsend/db gate boot; your client tables are non-fatal), reading schema drift off /v1/health, using db:push as a dev shortcut, or bypassing the boot guard with SKIP_SCHEMA_CHECK.
|
|
4
|
+
license: MIT
|
|
5
|
+
metadata:
|
|
6
|
+
author: withSeismic
|
|
7
|
+
version: "1.0.0"
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Hogsend Database
|
|
11
|
+
|
|
12
|
+
Hogsend runs **two independent migration tracks** against one Postgres
|
|
13
|
+
database. As a consumer (a scaffolded, content-only app) you own exactly one of
|
|
14
|
+
them: the **client track** — your own tables in `src/schema/index.ts`, your own
|
|
15
|
+
migrations in `./migrations`. The other — the **engine track** — is owned by
|
|
16
|
+
`@hogsend/db`, ships with `@hogsend/*` version bumps, and you never author it.
|
|
17
|
+
|
|
18
|
+
This skill helps you add tables, generate + apply migrations, and read schema
|
|
19
|
+
drift correctly without touching engine-internal files.
|
|
20
|
+
|
|
21
|
+
## Key concepts (read this first)
|
|
22
|
+
|
|
23
|
+
- **You own `src/schema/index.ts` only.** Define your app tables there with
|
|
24
|
+
Drizzle's `pgTable`. `pnpm db:generate` diffs that file and writes a migration
|
|
25
|
+
into `./migrations`; `pnpm db:migrate` applies it.
|
|
26
|
+
- **Engine tables live in `@hogsend/db`, not your repo.** `contacts`,
|
|
27
|
+
`journeyStates`, `emailSends`, `trackedLinks`, `linkClicks`,
|
|
28
|
+
`emailPreferences`, `bucketMemberships`, `userEvents`, auth tables, and the
|
|
29
|
+
rest are engine-owned. **Never redefine them in `src/schema/`** — import them
|
|
30
|
+
from `@hogsend/db` if you need to read/join them.
|
|
31
|
+
- **Two ledgers, one `drizzle` schema.** Engine track records into
|
|
32
|
+
`drizzle.__drizzle_migrations`; client track into `drizzle.__client_migrations`.
|
|
33
|
+
Your `drizzle.config.ts` is wired to the client ledger, so `db:generate`
|
|
34
|
+
can never collide with the engine's.
|
|
35
|
+
- **Drift gating is asymmetric.** Engine-track drift is **fatal at boot** — the
|
|
36
|
+
running build hard-requires its bundled engine schema. Client-track drift is
|
|
37
|
+
**non-fatal**; once you wire `clientJournal` into `createHogsendClient` it
|
|
38
|
+
surfaces on `GET /v1/health` as `status: "migration_pending"` for you to
|
|
39
|
+
resolve (the block is opt-in — see `references/migrations.md`). You may
|
|
40
|
+
legitimately deploy app code ahead of an additive client migration.
|
|
41
|
+
|
|
42
|
+
## The everyday flow
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pnpm db:generate # diff src/schema/index.ts -> new file in ./migrations
|
|
46
|
+
pnpm db:migrate # apply ENGINE track first, then your CLIENT track
|
|
47
|
+
# then confirm both tracks are inSync on GET /v1/health
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`scripts/migrate.ts` always runs engine-then-client (engine first so your client
|
|
51
|
+
tables can reference engine tables). Railway's `preDeployCommand` runs the same
|
|
52
|
+
`pnpm db:migrate` before every deploy.
|
|
53
|
+
|
|
54
|
+
## Task playbooks — load the matching reference
|
|
55
|
+
|
|
56
|
+
- **Add / change your own tables; what you may and may NOT touch** →
|
|
57
|
+
`references/client-track-schema.md`
|
|
58
|
+
- **The db:generate → db:migrate flow, drizzle.config, the migrations dir +
|
|
59
|
+
meta journal, the db:push shortcut** → `references/migrations.md`
|
|
60
|
+
- **Schema drift: fatal engine-track at boot vs non-fatal client-track on
|
|
61
|
+
/v1/health, SKIP_SCHEMA_CHECK, recovering a db:push'd ledger** →
|
|
62
|
+
`references/schema-drift.md`
|
|
63
|
+
|
|
64
|
+
## Cross-skill links
|
|
65
|
+
|
|
66
|
+
- Verifying a running instance's schema tracks (`schema.engine` /
|
|
67
|
+
`schema.client`, `inSync`) from the CLI — see the **hogsend-cli** skill
|
|
68
|
+
(`hogsend doctor --json`).
|
|
69
|
+
- Querying with conditions / property checks / time windows over engine tables
|
|
70
|
+
(events, email engagement) — see the **hogsend-conditions** skill.
|