@hogsend/cli 0.1.0 → 0.2.1
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 +575 -104
- package/dist/bin.js.map +1 -1
- package/package.json +4 -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 +117 -0
- package/skills/hogsend-authoring-journeys/references/journey-context.md +133 -0
- package/skills/hogsend-authoring-journeys/references/journey-meta.md +145 -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 +1 -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/commands/doctor.ts +22 -0
- package/src/commands/index.ts +4 -0
- package/src/commands/skills.ts +36 -96
- package/src/commands/studio.ts +261 -0
- package/src/commands/upgrade.ts +245 -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,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.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Client-track schema — what you own
|
|
2
|
+
|
|
3
|
+
Your app's tables live in **`src/schema/index.ts`** and migrate on the **client
|
|
4
|
+
track**. This is the only schema file you author. Engine tables live in
|
|
5
|
+
`@hogsend/db` and are off-limits to redefine.
|
|
6
|
+
|
|
7
|
+
## What you own vs what the engine owns
|
|
8
|
+
|
|
9
|
+
| | Owner | Where it lives | Ledger | Drift gating |
|
|
10
|
+
|---|---|---|---|---|
|
|
11
|
+
| **Client tables** (your app data) | You | `src/schema/index.ts` → `./migrations` | `drizzle.__client_migrations` | non-fatal → `/v1/health` |
|
|
12
|
+
| **Engine tables** (`contacts`, `journeyStates`, `emailSends`, `trackedLinks`, `linkClicks`, `emailPreferences`, `bucketMemberships`, `userEvents`, auth, alert/import/dlq tables, …) | `@hogsend/db` | inside the published `@hogsend/db` package | `drizzle.__drizzle_migrations` | **fatal at boot** |
|
|
13
|
+
|
|
14
|
+
**Rule:** never re-declare an engine table (`contacts`, `journey_states`,
|
|
15
|
+
`email_sends`, etc.) in `src/schema/`. If you do, `db:generate` will try to
|
|
16
|
+
create a table the engine already manages and your client migration will collide
|
|
17
|
+
with engine objects. Import them from `@hogsend/db` instead.
|
|
18
|
+
|
|
19
|
+
## Adding a table
|
|
20
|
+
|
|
21
|
+
Open `src/schema/index.ts` and add a `pgTable`. The scaffold ships a starter
|
|
22
|
+
table (`clientNotes`) you can rename or replace:
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
// src/schema/index.ts
|
|
26
|
+
import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* CLIENT-track schema. Engine tables (contacts, journeyStates, emailSends,
|
|
30
|
+
* tracking, ...) live in @hogsend/db and migrate on the ENGINE track — do NOT
|
|
31
|
+
* redefine them here. Add only your own app-specific tables.
|
|
32
|
+
*/
|
|
33
|
+
export const supportTickets = pgTable(
|
|
34
|
+
"support_tickets",
|
|
35
|
+
{
|
|
36
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
37
|
+
// Match the engine's contact identity: contacts.externalId is the user id
|
|
38
|
+
// you pass to ingest/journeys. Keep it text, not a FK, to stay decoupled.
|
|
39
|
+
userId: text("user_id").notNull(),
|
|
40
|
+
subject: text("subject").notNull(),
|
|
41
|
+
status: text("status").notNull().default("open"),
|
|
42
|
+
createdAt: timestamp("created_at", { withTimezone: true })
|
|
43
|
+
.defaultNow()
|
|
44
|
+
.notNull(),
|
|
45
|
+
},
|
|
46
|
+
(table) => [index("support_tickets_user_id_idx").on(table.userId)],
|
|
47
|
+
);
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Then generate + apply (see `migrations.md`):
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pnpm db:generate
|
|
54
|
+
pnpm db:migrate
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Conventions worth matching
|
|
58
|
+
|
|
59
|
+
Engine tables use these patterns; mirroring them keeps your schema consistent:
|
|
60
|
+
|
|
61
|
+
- **`uuid("id").primaryKey().defaultRandom()`** for surrogate keys.
|
|
62
|
+
- **`timestamp(..., { withTimezone: true })`** — always timezone-aware.
|
|
63
|
+
- **`text("user_id")`** to reference a contact by its external id (the same id
|
|
64
|
+
you pass to `ctx.trigger`/ingest). Engine tables denormalize `user_id` as
|
|
65
|
+
plain `text` rather than a hard FK to `contacts`, so you stay decoupled from
|
|
66
|
+
engine internals — do the same.
|
|
67
|
+
- Add `index(...)` on the columns you filter/sort by.
|
|
68
|
+
|
|
69
|
+
## Reading engine tables from your code
|
|
70
|
+
|
|
71
|
+
You don't redefine engine tables — you import them. The engine container exposes
|
|
72
|
+
the schema-aware Drizzle db; engine table objects come from `@hogsend/db`:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
// e.g. inside a custom workflow or route handler
|
|
76
|
+
import { contacts, emailSends } from "@hogsend/db";
|
|
77
|
+
import { eq } from "drizzle-orm";
|
|
78
|
+
|
|
79
|
+
// `db` is the container's Drizzle instance (c.get("container").db, or
|
|
80
|
+
// client.db). It already knows the full engine + your client schema.
|
|
81
|
+
const rows = await db
|
|
82
|
+
.select()
|
|
83
|
+
.from(contacts)
|
|
84
|
+
.where(eq(contacts.externalId, "user_123"));
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Joining your client table to an engine table (e.g. `support_tickets.userId` ↔
|
|
88
|
+
`contacts.externalId`) works because both are registered on the same Drizzle
|
|
89
|
+
instance — your client schema is bundled into the consumer build alongside
|
|
90
|
+
`@hogsend/db`.
|
|
91
|
+
|
|
92
|
+
## Don't edit
|
|
93
|
+
|
|
94
|
+
- Anything under `@hogsend/db` (it's a dependency, not your source).
|
|
95
|
+
- The engine ledger `drizzle.__drizzle_migrations`.
|
|
96
|
+
- The engine's `drizzle.config` / migration folder — you only have your own
|
|
97
|
+
client `drizzle.config.ts` and `./migrations`.
|