@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,117 @@
|
|
|
1
|
+
# The `BucketId` union + `bucketEntered` / `bucketLeft` ritual
|
|
2
|
+
|
|
3
|
+
When a user joins or leaves a bucket the engine emits a per-bucket ALIAS event:
|
|
4
|
+
`bucket:entered:<id>` / `bucket:left:<id>` (e.g. `bucket:entered:power-users`).
|
|
5
|
+
Journeys bind to those alias strings via `trigger.event`. The problem: a
|
|
6
|
+
journey's `trigger.event` is typed `string`, so a misspelled alias compiles fine
|
|
7
|
+
and the journey just silently never fires.
|
|
8
|
+
|
|
9
|
+
The fix is a hand-maintained literal union plus two typed helper functions, in
|
|
10
|
+
the CONSUMER's `src/journeys/constants/index.ts`. This is the ritual you MUST
|
|
11
|
+
keep in sync whenever you add or rename a bucket.
|
|
12
|
+
|
|
13
|
+
## The constants block
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
// src/journeys/constants/index.ts
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The union of bucket ids registered in src/buckets/index.ts. Keep this in sync
|
|
20
|
+
* with the `buckets` array — it is what makes the alias helpers catch a typo at
|
|
21
|
+
* COMPILE time.
|
|
22
|
+
*/
|
|
23
|
+
export type BucketId = "power-users";
|
|
24
|
+
|
|
25
|
+
// Narrow-alias helpers — ONLY accept a registered BucketId, so a typo such as
|
|
26
|
+
// bucketEntered("power-uesrs") is a compile error rather than a silently
|
|
27
|
+
// never-firing trigger. The return type is the EXACT literal event name, so it
|
|
28
|
+
// drops straight into a journey's trigger.event / exitOn rule.
|
|
29
|
+
export const bucketEntered = <T extends BucketId>(id: T) =>
|
|
30
|
+
`bucket:entered:${id}` as const;
|
|
31
|
+
|
|
32
|
+
export const bucketLeft = <T extends BucketId>(id: T) =>
|
|
33
|
+
`bucket:left:${id}` as const;
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
When you add a second bucket, the union grows by hand:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
export type BucketId = "power-users" | "went-dormant";
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Why it MUST be a hand-written literal union
|
|
43
|
+
|
|
44
|
+
You might be tempted to derive the union from the `buckets` array:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
// DON'T — this collapses to `string` and loses all typo-safety.
|
|
48
|
+
type BucketId = (typeof buckets)[number]["meta"]["id"];
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`defineBucket` widens `meta.id` to `string` (`BucketMeta.id: string`), so an
|
|
52
|
+
array-derived union evaluates to `string`. Then `bucketEntered("anything")`
|
|
53
|
+
type-checks, and the whole guard is gone. The explicit literal union is the
|
|
54
|
+
source of truth precisely because it can't be widened.
|
|
55
|
+
|
|
56
|
+
## How journeys consume the helpers
|
|
57
|
+
|
|
58
|
+
The helpers return the exact literal, so they drop straight into a journey's
|
|
59
|
+
`trigger.event` (and `exitOn`):
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { hours } from "@hogsend/core";
|
|
63
|
+
import { defineJourney, sendEmail } from "@hogsend/engine";
|
|
64
|
+
import { bucketEntered, bucketLeft, Templates } from "./constants/index.js";
|
|
65
|
+
|
|
66
|
+
export const winback = defineJourney({
|
|
67
|
+
meta: {
|
|
68
|
+
id: "winback",
|
|
69
|
+
name: "Win-back",
|
|
70
|
+
enabled: true,
|
|
71
|
+
// Enroll the moment a user lands in the went-dormant bucket.
|
|
72
|
+
trigger: { event: bucketEntered("went-dormant") },
|
|
73
|
+
entryLimit: "once_per_period",
|
|
74
|
+
// `suppress` is REQUIRED on every JourneyMeta — the re-entry cool-down.
|
|
75
|
+
suppress: hours(24),
|
|
76
|
+
// Pull them out the instant they re-activate (leave the bucket).
|
|
77
|
+
exitOn: [{ event: bucketLeft("went-dormant") }],
|
|
78
|
+
},
|
|
79
|
+
run: async (user) => {
|
|
80
|
+
await sendEmail({
|
|
81
|
+
to: user.email,
|
|
82
|
+
userId: user.id,
|
|
83
|
+
journeyStateId: user.stateId,
|
|
84
|
+
template: Templates.ACTIVATION_NUDGE,
|
|
85
|
+
subject: "We miss you",
|
|
86
|
+
journeyName: user.journeyName,
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
A typo'd id — `bucketEntered("went-dorment")` — is now a compile error.
|
|
93
|
+
|
|
94
|
+
## Generic forms vs aliases
|
|
95
|
+
|
|
96
|
+
The constants file also defines the GENERIC events:
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
export const Events = {
|
|
100
|
+
// ...
|
|
101
|
+
BUCKET_ENTERED: "bucket:entered",
|
|
102
|
+
BUCKET_LEFT: "bucket:left",
|
|
103
|
+
} as const;
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
These fire for ANY bucket. The engine only emits the generic `bucket:entered` /
|
|
107
|
+
`bucket:left` when a journey actually binds to it (otherwise it's not written at
|
|
108
|
+
all). Prefer the narrowly-routed per-bucket aliases (`bucketEntered(id)`) for
|
|
109
|
+
real journey bindings — bind to the generic forms only if you genuinely want
|
|
110
|
+
"any bucket transition" routing.
|
|
111
|
+
|
|
112
|
+
## The checklist when adding a bucket
|
|
113
|
+
|
|
114
|
+
1. Add the `defineBucket(...)` in `src/buckets/`.
|
|
115
|
+
2. Add its `meta.id` literal to the `BucketId` union.
|
|
116
|
+
3. Register it in `src/buckets/index.ts` (see register-a-bucket).
|
|
117
|
+
4. Bind journeys with `bucketEntered("<id>")` / `bucketLeft("<id>")`.
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# Authoring a bucket's `meta`
|
|
2
|
+
|
|
3
|
+
A bucket is `defineBucket({ meta })`. The `meta` (a `BucketMeta`) is the whole
|
|
4
|
+
declaration — the engine derives everything (registry indexes, reconcile
|
|
5
|
+
behavior, Studio size) from it. This is the field-by-field guide.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { defineBucket } from "@hogsend/engine";
|
|
9
|
+
import { days } from "@hogsend/core";
|
|
10
|
+
import { Events } from "../journeys/constants/index.js";
|
|
11
|
+
|
|
12
|
+
export const powerUsers = defineBucket({
|
|
13
|
+
meta: {
|
|
14
|
+
id: "power-users", // also the alias suffix: bucket:entered:power-users
|
|
15
|
+
name: "Power users",
|
|
16
|
+
description: "Used the key feature 10+ times in the last 30 days.",
|
|
17
|
+
enabled: true,
|
|
18
|
+
timeBased: true, // rolling window → reconcile owns the leave
|
|
19
|
+
entryLimit: "once_per_period",
|
|
20
|
+
entryPeriod: { hours: 24 * 7 }, // 7-day cooldown before a re-emit
|
|
21
|
+
criteria: {
|
|
22
|
+
type: "event",
|
|
23
|
+
eventName: Events.FEATURE_USED,
|
|
24
|
+
check: "count",
|
|
25
|
+
operator: "gte",
|
|
26
|
+
value: 10,
|
|
27
|
+
within: days(30), // makes the bucket time-based
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## The fields
|
|
34
|
+
|
|
35
|
+
| Field | Type | Notes |
|
|
36
|
+
|-------|------|-------|
|
|
37
|
+
| `id` | `string` (required) | Stable identity AND the alias suffix. Changing it makes a NEW bucket. Keep it in the `BucketId` union (see bucket-id-aliases). |
|
|
38
|
+
| `name` | `string` (required) | Human label, surfaced in Studio + the emitted `bucketName`. |
|
|
39
|
+
| `description` | `string?` | Free text. |
|
|
40
|
+
| `enabled` | `boolean` (required) | Static load-time on/off (guard #1), mirrors a journey's `enabled`. A disabled bucket is not registered. |
|
|
41
|
+
| `kind` | `"dynamic" \| "manual"?` | Defaults `"dynamic"`. `"manual"` is REJECTED at registration in v1 — always use dynamic + `criteria`. |
|
|
42
|
+
| `criteria` | `ConditionEval?` | The membership predicate. REQUIRED for dynamic buckets. |
|
|
43
|
+
| `entryLimit` | `"once" \| "once_per_period" \| "unlimited"?` | Defaults `"unlimited"`. Gates re-EMISSION of `bucket:entered` on a re-join. |
|
|
44
|
+
| `entryPeriod` | `DurationObject?` | The cooldown for `"once_per_period"`: re-emit only once this elapses since the prior LEAVE. |
|
|
45
|
+
| `minDwell` | `DurationObject?` | Anti-flap floor: defer (never drop) `bucket:left` until membership is at least this old. |
|
|
46
|
+
| `maxDwell` | `DurationObject?` | Unconditional membership TTL: force-leave N after join REGARDLESS of criteria. |
|
|
47
|
+
| `timeBased` | `boolean?` | Marks that a clock (not an event) can flip membership. Inferred from a `within` window if omitted; set it explicitly for clarity. |
|
|
48
|
+
| `reconcileEvery` | `DurationObject?` | Advisory cadence surfaced in Studio (one engine-wide cron sweeps all time-based buckets; this is informational). |
|
|
49
|
+
| `reconcileJoins` | `boolean?` | Tri-state. `false` = hard off; `true` = explicit on; `undefined` = inferred on only for safe absence shapes. See "Time-based" below. |
|
|
50
|
+
| `fastExpiry` | `boolean?` | Opt-in per-user durable timer for sub-second absence leaves. Defaults `false`; cron is the backstop. Requires worker wiring. |
|
|
51
|
+
| `syncToPostHog` | `boolean?` | Mirror membership to a PostHog person property on join/leave. Off by default; no-op without `POSTHOG_API_KEY`. |
|
|
52
|
+
| `postHogPropertyKey` | `string?` | Override the synced property name (default `hogsend_bucket_<id>`). |
|
|
53
|
+
|
|
54
|
+
## `criteria` — two authoring forms
|
|
55
|
+
|
|
56
|
+
Both forms produce the SAME `ConditionEval` data. The builder runs ONCE at
|
|
57
|
+
definition time and is resolved to declarative data by `defineBucket`, so the
|
|
58
|
+
registry / schema / reconcile only ever see the canonical tree.
|
|
59
|
+
|
|
60
|
+
**Declarative tree** (a `ConditionEval`):
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
criteria: {
|
|
64
|
+
type: "composite",
|
|
65
|
+
operator: "and",
|
|
66
|
+
conditions: [
|
|
67
|
+
{ type: "property", property: "plan", operator: "eq", value: "pro" },
|
|
68
|
+
{
|
|
69
|
+
type: "event",
|
|
70
|
+
eventName: Events.FEATURE_USED,
|
|
71
|
+
check: "count",
|
|
72
|
+
operator: "gte",
|
|
73
|
+
value: 5,
|
|
74
|
+
within: days(14),
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**Fluent builder** — `criteria` accepts `(b: CriteriaBuilder) => ConditionEval`:
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
criteria: (b) =>
|
|
84
|
+
b.all(
|
|
85
|
+
b.prop("plan").eq("pro"),
|
|
86
|
+
b.event(Events.FEATURE_USED).within(days(14)).atLeast(5),
|
|
87
|
+
),
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Builder surface: `b.prop(name)` → `.eq/.neq/.gt/.gte/.lt/.lte/.contains/.exists/.notExists`;
|
|
91
|
+
`b.event(name)` → optional `.within(window)` then a terminal
|
|
92
|
+
`.exists()/.notExists()/.count(op, n)/.atLeast(n)/.moreThan(n)/.atMost(n)/.lessThan(n)/.exactly(n)`;
|
|
93
|
+
`b.all(...)`/`b.any(...)` for AND/OR composites.
|
|
94
|
+
|
|
95
|
+
For the full operator table, window semantics, and how `event` count/within
|
|
96
|
+
windows evaluate, see the hogsend-conditions skill — bucket criteria are the
|
|
97
|
+
exact same condition system.
|
|
98
|
+
|
|
99
|
+
## Registration validation (fail-fast rules)
|
|
100
|
+
|
|
101
|
+
`BucketRegistry.register()` runs `bucketMetaSchema.parse()`, so these throw at
|
|
102
|
+
client/worker boot, not silently:
|
|
103
|
+
|
|
104
|
+
- **At least one positive leaf.** A dynamic bucket whose every leaf is negative
|
|
105
|
+
(`property neq`/`not_exists`, `event not_exists` with NO `within`,
|
|
106
|
+
`email_engagement not_opened`/`not_clicked`) is degenerate. Exception: an
|
|
107
|
+
`event ... not_exists` WITH a `within` window is a valid time-bounded dormancy
|
|
108
|
+
anchor and counts as legitimate.
|
|
109
|
+
- **No reserved event names.** No `EventCondition.eventName` may start with
|
|
110
|
+
`bucket:` — transition rows must never satisfy a predicate.
|
|
111
|
+
- **No `email_engagement` in v1.** Engagement conditions are not allowed in
|
|
112
|
+
bucket criteria (they ARE allowed in journey conditions).
|
|
113
|
+
- **`maxDwell >= minDwell`** when both are set.
|
|
114
|
+
- **`kind:"manual"` is rejected.** Use `kind:"dynamic"`.
|
|
115
|
+
|
|
116
|
+
## Time-based buckets (rolling windows + reconcile)
|
|
117
|
+
|
|
118
|
+
A `within` window means a user can fall OUT with no inbound event (the window
|
|
119
|
+
just rolls past). The real-time path structurally can't catch that, so the
|
|
120
|
+
engine-wide reconcile cron sweeps every time-based dynamic bucket and leaves
|
|
121
|
+
members whose criteria no longer hold.
|
|
122
|
+
|
|
123
|
+
- **Leaves** are handled automatically for any time-based bucket — no extra
|
|
124
|
+
config. The 30-day `power-users` example above leaves a user the moment they
|
|
125
|
+
drop below 10 uses in the trailing 30 days, swept by the cron.
|
|
126
|
+
- **Absence joins** (a user who STOPPED doing X fires no event) need the cron to
|
|
127
|
+
materialize the join. `reconcileJoins` is INFERRED on for the two safe
|
|
128
|
+
set-based absence shapes — a single windowed `event(X).within(W).notExists()`,
|
|
129
|
+
and the lapsed-active composite `all(event(X).exists(), event(X).within(W).notExists())`.
|
|
130
|
+
Any other absence-containing composite (OR-of-absence, absence mixed with
|
|
131
|
+
property/count) needs an explicit `reconcileJoins: true`.
|
|
132
|
+
- **`maxDwell`** is a hard time-box independent of criteria — pair with
|
|
133
|
+
`entryLimit:"once"`/`"once_per_period"` for "in for exactly N then out", or
|
|
134
|
+
leave the default `"unlimited"` for a periodic flush (re-join on next
|
|
135
|
+
qualifying event).
|
|
136
|
+
- **`fastExpiry: true`** arms a per-user durable timer so the leave lands
|
|
137
|
+
sub-second instead of waiting for the next cron tick. It needs the bucket
|
|
138
|
+
wired into `createWorker` (see register-a-bucket); the cron is still the
|
|
139
|
+
authoritative backstop.
|
|
140
|
+
|
|
141
|
+
Reconcile cadence is the `BUCKET_RECONCILE_CRON` env var (default `*/5 * * * *`),
|
|
142
|
+
so time-based exits land within that cadence, not to-the-second.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Bucket vs journey — when to use which
|
|
2
|
+
|
|
3
|
+
Buckets and journeys are peers built on the same ingestion spine, but they
|
|
4
|
+
answer different questions:
|
|
5
|
+
|
|
6
|
+
- A **bucket** answers **"who is in this audience right NOW?"** It is continuous
|
|
7
|
+
membership. A user is in or out at every instant based on whether their data
|
|
8
|
+
satisfies `criteria`. There is no flow, no steps, no sleeps — just a
|
|
9
|
+
`bucket_memberships` row that flips active/left as the data changes.
|
|
10
|
+
- A **journey** answers **"run this one-shot durable flow for a user."** It is a
|
|
11
|
+
TypeScript control-flow process: send, `ctx.sleep`, branch, send again. It has
|
|
12
|
+
a beginning and an end, durable state, and enrollment guards.
|
|
13
|
+
|
|
14
|
+
Buckets DRIVE journeys: a join/leave transition emits an event, and a journey
|
|
15
|
+
can trigger (or exit) on that event. That's the whole integration.
|
|
16
|
+
|
|
17
|
+
## Pick a bucket when…
|
|
18
|
+
|
|
19
|
+
- You're describing a STATE that comes and goes: "power users", "trial ending
|
|
20
|
+
this week", "went dormant", "on the pro plan and active".
|
|
21
|
+
- Membership should self-heal as data changes — including time-based exits (a
|
|
22
|
+
rolling `within` window rolling past) with no inbound event. The reconcile
|
|
23
|
+
cron owns those leaves; a journey cannot observe "nothing happened".
|
|
24
|
+
- You want to reuse the same audience for MANY downstream flows. One bucket, N
|
|
25
|
+
journeys binding to its transitions.
|
|
26
|
+
- You want the audience size visible in Studio.
|
|
27
|
+
|
|
28
|
+
## Pick a journey when…
|
|
29
|
+
|
|
30
|
+
- You're describing a SEQUENCE with timing: welcome series, dunning, onboarding
|
|
31
|
+
nudges. Steps, durable sleeps, branches.
|
|
32
|
+
- The thing is a one-time reaction to an event, not an ongoing membership.
|
|
33
|
+
- You need per-step email sends, history checks, cross-journey triggers, etc.
|
|
34
|
+
|
|
35
|
+
## How transitions drive journeys
|
|
36
|
+
|
|
37
|
+
On a real join the engine emits `bucket:entered:<id>`; on a leave,
|
|
38
|
+
`bucket:left:<id>`. Both flow through the SAME `ingestEvent` pipeline a normal
|
|
39
|
+
event does, so journeys route on them exactly like any other trigger. Bind with
|
|
40
|
+
the typed `bucketEntered`/`bucketLeft` helpers (see bucket-id-aliases):
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { days, hours } from "@hogsend/core";
|
|
44
|
+
import { defineJourney, sendEmail } from "@hogsend/engine";
|
|
45
|
+
import { bucketEntered, bucketLeft, Templates } from "./constants/index.js";
|
|
46
|
+
|
|
47
|
+
export const powerUserOnboarding = defineJourney({
|
|
48
|
+
meta: {
|
|
49
|
+
id: "power-user-onboarding",
|
|
50
|
+
name: "Power-user onboarding",
|
|
51
|
+
enabled: true,
|
|
52
|
+
trigger: { event: bucketEntered("power-users") }, // join → start the flow
|
|
53
|
+
entryLimit: "once_per_period",
|
|
54
|
+
suppress: hours(24), // required re-entry cool-down
|
|
55
|
+
exitOn: [{ event: bucketLeft("power-users") }], // leave → pull them out
|
|
56
|
+
},
|
|
57
|
+
run: async (user, ctx) => {
|
|
58
|
+
await sendEmail({
|
|
59
|
+
to: user.email,
|
|
60
|
+
userId: user.id,
|
|
61
|
+
journeyStateId: user.stateId,
|
|
62
|
+
template: Templates.ACTIVATION_WELCOME,
|
|
63
|
+
subject: "You're a power user now — here's the deep dive",
|
|
64
|
+
journeyName: user.journeyName,
|
|
65
|
+
});
|
|
66
|
+
await ctx.sleep({ duration: days(3), label: "follow-up" });
|
|
67
|
+
// ...continue the flow
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## The re-emit / debounce knobs work TOGETHER
|
|
73
|
+
|
|
74
|
+
A bucket and the journeys it drives both have entry policies; tune them so
|
|
75
|
+
oscillation doesn't spam:
|
|
76
|
+
|
|
77
|
+
- **Bucket `minDwell`** debounces flapping membership — it defers `bucket:left`
|
|
78
|
+
until membership has existed at least that long, so a user bouncing in and out
|
|
79
|
+
doesn't fire a leave-then-enter storm.
|
|
80
|
+
- **Bucket `entryLimit` / `entryPeriod`** gate when a RE-join re-emits
|
|
81
|
+
`bucket:entered` (e.g. `"once_per_period"` with a 7-day `entryPeriod` won't
|
|
82
|
+
re-emit a join within a week of the prior leave).
|
|
83
|
+
- **Journey `entryLimit` / `suppress`** are the journey's own re-entry guard on
|
|
84
|
+
top of that.
|
|
85
|
+
|
|
86
|
+
Rule of thumb: shape the audience on the bucket (`criteria`, `minDwell`,
|
|
87
|
+
`entryLimit`), shape the messaging cadence on the journey (`suppress`,
|
|
88
|
+
`exitOn`). For the condition/duration semantics shared by both, see the
|
|
89
|
+
hogsend-conditions skill.
|
|
90
|
+
|
|
91
|
+
## What buckets are NOT
|
|
92
|
+
|
|
93
|
+
Buckets are observe-only in Studio — there is no visual builder, exactly like
|
|
94
|
+
journeys. They live in code. And `kind:"manual"` (membership mutated only by
|
|
95
|
+
explicit API/import) is declared on the type for forward-compat but is REJECTED
|
|
96
|
+
at registration in v1 — every bucket today is `kind:"dynamic"` with `criteria`.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Registering + wiring a bucket (dual wiring)
|
|
2
|
+
|
|
3
|
+
A bucket only does anything once it is (1) exported from the barrel and (2)
|
|
4
|
+
threaded into BOTH engine factories. This is the dual wiring the SKILL keeps
|
|
5
|
+
warning about: `createHogsendClient` AND `createWorker`. Miss either and the
|
|
6
|
+
bucket is half-alive.
|
|
7
|
+
|
|
8
|
+
## 1. Export from `src/buckets/index.ts`
|
|
9
|
+
|
|
10
|
+
The barrel exports the `buckets: DefinedBucket[]` array — this is the single
|
|
11
|
+
list both factories consume.
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
// src/buckets/index.ts
|
|
15
|
+
import type { DefinedBucket } from "@hogsend/engine";
|
|
16
|
+
import { powerUsers } from "./power-users.js";
|
|
17
|
+
import { wentDormant } from "./went-dormant.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* All defined buckets for this app. Passed to createHogsendClient({ buckets })
|
|
21
|
+
* and createWorker({ buckets }). Edit freely — this is your content.
|
|
22
|
+
*/
|
|
23
|
+
export const buckets: DefinedBucket[] = [powerUsers, wentDormant];
|
|
24
|
+
|
|
25
|
+
// Re-export individual buckets for direct reference (tests, custom wiring).
|
|
26
|
+
export { powerUsers, wentDormant };
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Also add the new id to the `BucketId` union in
|
|
30
|
+
`src/journeys/constants/index.ts` (see bucket-id-aliases) — that's part of
|
|
31
|
+
"registering" a bucket as far as typo-safety goes.
|
|
32
|
+
|
|
33
|
+
## 2. Thread into `createHogsendClient` (the registry + real-time + reconcile)
|
|
34
|
+
|
|
35
|
+
In `src/index.ts` (the HTTP entry point), the client receives `buckets`. This
|
|
36
|
+
builds the `BucketRegistry`, installs it as the process singleton (so the
|
|
37
|
+
real-time ingest path and the reconcile cron can resolve it), and validates
|
|
38
|
+
every `meta` via `bucketMetaSchema.parse()`.
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
// src/index.ts
|
|
42
|
+
import { createApp, createHogsendClient } from "@hogsend/engine";
|
|
43
|
+
import { buckets } from "./buckets/index.js";
|
|
44
|
+
import { templates } from "./emails/index.js";
|
|
45
|
+
import { journeys } from "./journeys/index.js";
|
|
46
|
+
import { webhookSources } from "./webhook-sources/index.js";
|
|
47
|
+
|
|
48
|
+
const client = createHogsendClient({ journeys, buckets, email: { templates } });
|
|
49
|
+
|
|
50
|
+
// ...schema boot-guard...
|
|
51
|
+
|
|
52
|
+
const app = createApp(client, { webhookSources });
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## 3. Thread into `createWorker` (fast-expiry timer + boot backfill)
|
|
56
|
+
|
|
57
|
+
In `src/worker.ts` (the task-execution entry point), BOTH the client AND the
|
|
58
|
+
worker get `buckets`. The client call here installs the registry for the worker
|
|
59
|
+
process; the `createWorker({ buckets })` call registers the per-user fast-expiry
|
|
60
|
+
timer task for any bucket with `fastExpiry: true`.
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
// src/worker.ts
|
|
64
|
+
import { createHogsendClient, createWorker } from "@hogsend/engine";
|
|
65
|
+
import { buckets } from "./buckets/index.js";
|
|
66
|
+
import { templates } from "./emails/index.js";
|
|
67
|
+
import { journeys } from "./journeys/index.js";
|
|
68
|
+
import { extraWorkflows } from "./workflows/index.js";
|
|
69
|
+
|
|
70
|
+
async function main() {
|
|
71
|
+
const client = createHogsendClient({
|
|
72
|
+
journeys,
|
|
73
|
+
buckets,
|
|
74
|
+
email: { templates },
|
|
75
|
+
});
|
|
76
|
+
const worker = createWorker({
|
|
77
|
+
container: client,
|
|
78
|
+
journeys,
|
|
79
|
+
buckets, // ← registers fastExpiry timer task(s) for opted-in buckets
|
|
80
|
+
extraWorkflows,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ...signal handlers...
|
|
84
|
+
await worker.start();
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## What each side gives you
|
|
89
|
+
|
|
90
|
+
| Wiring | What it enables |
|
|
91
|
+
|--------|-----------------|
|
|
92
|
+
| `createHogsendClient({ buckets })` | Builds + installs the `BucketRegistry` singleton; validates every `meta`; powers the real-time join/leave eval inside `ingestEvent`; lets the reconcile cron resolve enabled buckets. Required in BOTH `index.ts` and `worker.ts`. |
|
|
93
|
+
| `createWorker({ buckets })` | Registers the single shared `bucket:arm-expiry` durable timer task — but ONLY if some enabled bucket has `fastExpiry: true`. Triggers the boot-time backfill / criteria-change re-eval. |
|
|
94
|
+
|
|
95
|
+
If you ONLY wire the client: time-based and fast-expiry leaves never run (no
|
|
96
|
+
worker tasks), and a new bucket is never backfilled. If you ONLY wire the worker
|
|
97
|
+
(client `buckets` empty): the registry is empty, so the worker's tasks resolve
|
|
98
|
+
nothing.
|
|
99
|
+
|
|
100
|
+
## The reconcile cron (engine-owned — you don't register it)
|
|
101
|
+
|
|
102
|
+
The engine ALWAYS registers `bucketReconcileTask` and `bucketBackfillTask` in
|
|
103
|
+
the worker's base workflows — you do NOT add them to `extraWorkflows`. The cron:
|
|
104
|
+
|
|
105
|
+
- runs on `BUCKET_RECONCILE_CRON` (default `*/5 * * * *`), non-cancelling (an
|
|
106
|
+
overrunning sweep queues, never cancels);
|
|
107
|
+
- sweeps every enabled time-based / `maxDwell` dynamic bucket and emits
|
|
108
|
+
`bucket:left` (and absence `bucket:entered`) for members the clock moved;
|
|
109
|
+
- is the authoritative backstop even when `fastExpiry` is on.
|
|
110
|
+
|
|
111
|
+
The boot backfill (`bucketBackfillTask`, kicked off by the worker on start):
|
|
112
|
+
|
|
113
|
+
- on a NEW bucket id → materializes the full member set from history WITHOUT
|
|
114
|
+
emitting `bucket:entered` (no historical blast into live journeys);
|
|
115
|
+
- on a CHANGED `criteria` (detected via a stored hash diff) → re-evaluates: joins
|
|
116
|
+
new matchers silently, and emits `bucket:left` for members who no longer match.
|
|
117
|
+
|
|
118
|
+
So: change a bucket's `criteria` and redeploy the worker → the engine
|
|
119
|
+
automatically reconciles existing memberships on boot. You don't run a migration.
|
|
120
|
+
|
|
121
|
+
## Enabling / disabling at load time
|
|
122
|
+
|
|
123
|
+
- `meta.enabled: false` keeps a bucket out of the registry entirely.
|
|
124
|
+
- The `ENABLED_BUCKETS` env var (comma-separated ids, or `*` for all) filters
|
|
125
|
+
which buckets load, mirroring `ENABLED_JOURNEYS`. You can override per-call via
|
|
126
|
+
`createHogsendClient({ enabledBuckets })` / `createWorker({ enabledBuckets })`.
|
|
127
|
+
|
|
128
|
+
To verify a bucket is live on a running instance (membership counts, transition
|
|
129
|
+
events), see the hogsend-cli skill.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: hogsend-authoring-emails
|
|
3
|
+
description: Use when adding or editing a transactional email in src/emails/ — creating a react-email .tsx, keeping the four-file contract (component, types.ts props, registry.ts entry, templates.d.ts augmentation) in sync with the Templates constant key, sharing _components, plaintext/preview, and how link-click + open tracking and unsubscribe are applied automatically on send.
|
|
4
|
+
license: MIT
|
|
5
|
+
metadata:
|
|
6
|
+
author: withSeismic
|
|
7
|
+
version: "1.0.0"
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Authoring Hogsend emails
|
|
11
|
+
|
|
12
|
+
A Hogsend email is a [react-email](https://react.email) component plus three
|
|
13
|
+
small sidecar declarations that make it sendable and type-checked. You author
|
|
14
|
+
them in your app's `src/emails/`; `@hogsend/email` is render machinery only — it
|
|
15
|
+
bakes in no concrete templates. You call the engine factories and import from
|
|
16
|
+
`@hogsend/engine` / `@hogsend/email`; you never edit engine internals.
|
|
17
|
+
|
|
18
|
+
The send pipeline is engine-owned: the same `createTrackedMailer` that renders
|
|
19
|
+
your template also rewrites every link for click tracking, injects an open
|
|
20
|
+
pixel, runs preference/suppression checks, and writes the `email_sends` row —
|
|
21
|
+
automatically, before the email reaches the provider. That is why tracking and
|
|
22
|
+
unsubscribe live in THIS skill, not a separate one: you get them for free on
|
|
23
|
+
every send, and what you author in `src/emails/` only has to leave room for them
|
|
24
|
+
(an `unsubscribeUrl` slot in the footer).
|
|
25
|
+
|
|
26
|
+
## The five touch points that must agree
|
|
27
|
+
|
|
28
|
+
Every template is really FIVE coordinated edits keyed on one string:
|
|
29
|
+
|
|
30
|
+
1. `src/emails/<name>.tsx` — the react-email component (default export).
|
|
31
|
+
2. `src/emails/types.ts` — its `Props` interface.
|
|
32
|
+
3. `src/emails/registry.ts` — a `templates[key]` entry (component + subject).
|
|
33
|
+
4. `src/emails/templates.d.ts` — augments `TemplateRegistryMap` so `key → Props`.
|
|
34
|
+
5. `src/journeys/constants/index.ts` — the `Templates.*` constant journeys send.
|
|
35
|
+
|
|
36
|
+
If the key in (3), (4), and (5) drifts, or the `Props` in (2)/(4) disagree, you
|
|
37
|
+
get a type error at the send call site — the #1 trap. See
|
|
38
|
+
`references/template-four-file-contract.md`.
|
|
39
|
+
|
|
40
|
+
## Key concepts
|
|
41
|
+
|
|
42
|
+
- **Registry is wired once.** `src/emails/index.ts` exports `templates`;
|
|
43
|
+
`src/index.ts` passes it as `createHogsendClient({ email: { templates } })`.
|
|
44
|
+
You only edit `src/emails/`; the wiring already exists.
|
|
45
|
+
- **Shared chrome lives in `src/emails/_components/`** — `Layout` (preview +
|
|
46
|
+
card + footer), `ui` primitives (`Title`/`Body`/`Button`/`Callout`/…), `Logo`,
|
|
47
|
+
`Footer`. Compose these instead of raw HTML.
|
|
48
|
+
- **Subject + preview + category** are declared on the registry entry, not in the
|
|
49
|
+
component. `defaultSubject` is the fallback subject; `preview` is the inbox
|
|
50
|
+
snippet; `category` drives frequency-cap exemption (`transactional` is exempt).
|
|
51
|
+
- **Tracking + unsubscribe are automatic on `emailService.send` / `sendEmail`.**
|
|
52
|
+
You never call `prepareTrackedHtml` or `generateUnsubscribeUrl` yourself.
|
|
53
|
+
|
|
54
|
+
## Task playbooks — load the matching reference
|
|
55
|
+
|
|
56
|
+
- **Add or rename a template and keep all five touch points in sync (and read
|
|
57
|
+
the type-error trap)** → `references/template-four-file-contract.md`
|
|
58
|
+
- **Build the component itself: shared `_components`, props typing, preview /
|
|
59
|
+
category / defaultSubject, plaintext** → `references/email-components.md`
|
|
60
|
+
- **Render or preview a template, and how the consumer registry is built /
|
|
61
|
+
threaded** → `references/preview-and-render.md`
|
|
62
|
+
- **What happens automatically on send: link-click + open tracking, the
|
|
63
|
+
`/v1/t/*` endpoints, the unsubscribe token/URL + preference checks** →
|
|
64
|
+
`references/tracking-and-unsubscribe.md`
|
|
65
|
+
|
|
66
|
+
To send a template from inside a lifecycle flow, see the
|
|
67
|
+
**hogsend-authoring-journeys** skill. To inspect a send / open / click rate
|
|
68
|
+
against a live instance, see the **hogsend-cli** skill.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Building the component — shared `_components`, props, preview/category, plaintext
|
|
2
|
+
|
|
3
|
+
You write the `.tsx` with [react-email](https://react.email) primitives, but
|
|
4
|
+
compose the shared chrome in `src/emails/_components/` instead of hand-rolling
|
|
5
|
+
HTML. That keeps every email visually consistent and leaves the right slots open
|
|
6
|
+
for the engine to inject tracking + unsubscribe.
|
|
7
|
+
|
|
8
|
+
## The shared `_components`
|
|
9
|
+
|
|
10
|
+
| Module | Exports | Role |
|
|
11
|
+
|--------|---------|------|
|
|
12
|
+
| `_components/layout.js` | `Layout` | The shell: `<Html>` + `<Head>` + `<Preview>` + `<Tailwind>`, the wordmark, one bordered white card, then the footer. |
|
|
13
|
+
| `_components/ui.js` | `Eyebrow`, `Title`, `Body`, `Button`, `Callout`, `CodeBlock`, `Bullets`, `Divider` | Email-safe design-system primitives. |
|
|
14
|
+
| `_components/logo.js` | `Logo` | Your wordmark above the card. |
|
|
15
|
+
| `_components/footer.js` | `Footer` | Renders the `unsubscribeUrl` / `preferencesUrl` links (rendered by `Layout`, not directly by you). |
|
|
16
|
+
|
|
17
|
+
`Layout`'s props are the only surface most templates touch:
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
interface LayoutProps {
|
|
21
|
+
preview: string; // inbox snippet — required
|
|
22
|
+
eyebrow?: string; // small uppercase label above the heading
|
|
23
|
+
unsubscribeUrl?: string; // forwarded to <Footer>
|
|
24
|
+
preferencesUrl?: string; // forwarded to <Footer>
|
|
25
|
+
children: ReactNode;
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
A minimal template composes them like this:
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
// biome-ignore lint/correctness/noUnusedImports: required for JSX runtime
|
|
33
|
+
import React from "react";
|
|
34
|
+
import { Layout } from "./_components/layout.js";
|
|
35
|
+
import { Body, Bullets, Button, Callout, Divider, Title } from "./_components/ui.js";
|
|
36
|
+
import type { TrialEndingEmailProps } from "./types.js";
|
|
37
|
+
|
|
38
|
+
export default function TrialEndingEmail({
|
|
39
|
+
name = "there",
|
|
40
|
+
daysLeft = 3,
|
|
41
|
+
upgradeUrl = "https://app.example.com/billing",
|
|
42
|
+
unsubscribeUrl,
|
|
43
|
+
}: TrialEndingEmailProps) {
|
|
44
|
+
return (
|
|
45
|
+
<Layout
|
|
46
|
+
preview={`${daysLeft} days left on your trial`}
|
|
47
|
+
eyebrow="Heads up"
|
|
48
|
+
unsubscribeUrl={unsubscribeUrl}
|
|
49
|
+
>
|
|
50
|
+
<Title>Your trial ends in {daysLeft} days</Title>
|
|
51
|
+
<Body>Hey {name}, here's what you keep when you upgrade:</Body>
|
|
52
|
+
<Bullets items={["Unlimited journeys", "Full event history", "Priority support"]} />
|
|
53
|
+
<Divider />
|
|
54
|
+
<Button href={upgradeUrl}>Upgrade now</Button>
|
|
55
|
+
<Callout tone="warn">Card on file is never charged automatically.</Callout>
|
|
56
|
+
</Layout>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
These files live in YOUR repo and are yours to edit — change the colors, add
|
|
62
|
+
primitives, swap `Logo` for a hosted `<Img>`. Use ESM `.js` extensions on the
|
|
63
|
+
relative imports (consumer convention), even though the files are `.tsx`.
|
|
64
|
+
|
|
65
|
+
## Props typing
|
|
66
|
+
|
|
67
|
+
Define the props interface in `src/emails/types.ts` and import it into the
|
|
68
|
+
component. Two conventions:
|
|
69
|
+
|
|
70
|
+
- **Default every prop in the destructure** (`name = "there"`) so previews and
|
|
71
|
+
admin catalogs render without real data, and a missing prop never produces
|
|
72
|
+
`undefined` in the body.
|
|
73
|
+
- **Always accept an optional `unsubscribeUrl?: string`.** The engine injects it
|
|
74
|
+
on send so `Layout`/`Footer` can render the unsubscribe link — see
|
|
75
|
+
`tracking-and-unsubscribe.md`. Make it optional; direct render/preview calls
|
|
76
|
+
won't pass it.
|
|
77
|
+
|
|
78
|
+
## Subject, preview, category — on the registry entry, not the component
|
|
79
|
+
|
|
80
|
+
These live on the `TemplateDefinition` in `src/emails/registry.ts`, NOT inside
|
|
81
|
+
the `.tsx`:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
export interface TemplateDefinition<P = Record<string, unknown>> {
|
|
85
|
+
component: (props: P) => ReactElement;
|
|
86
|
+
defaultSubject: string; // fallback subject when send() omits `subject`
|
|
87
|
+
category?: string; // e.g. "transactional" | "journey"
|
|
88
|
+
preview?: (props: P) => string; // inbox snippet, computed from props
|
|
89
|
+
examples?: Partial<P>; // sample props for admin previews only
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
- **`defaultSubject`** is used when the send call doesn't pass an explicit
|
|
94
|
+
`subject`. Journeys usually pass their own subject; transactional one-offs lean
|
|
95
|
+
on the default.
|
|
96
|
+
- **`category`** drives frequency capping. The engine's frequency cap exempts
|
|
97
|
+
`"transactional"` by default — mark genuine transactional mail (receipts,
|
|
98
|
+
password resets) `"transactional"` and marketing/lifecycle mail `"journey"` so
|
|
99
|
+
caps apply correctly.
|
|
100
|
+
- **`preview`** sets the snippet most inboxes show next to the subject. Note the
|
|
101
|
+
`Layout`'s own `preview` prop renders react-email's hidden `<Preview>` text in
|
|
102
|
+
the HTML; the registry `preview` is the value surfaced to admin/preview tooling
|
|
103
|
+
via `getPreviewText`. Keep them consistent.
|
|
104
|
+
|
|
105
|
+
## Plaintext
|
|
106
|
+
|
|
107
|
+
You do not author a separate plaintext file. The engine renders both halves from
|
|
108
|
+
the same component: `renderToHtml(element)` and `renderToPlainText(element)`
|
|
109
|
+
(react-email's `render(element, { plainText: true })`). Write the component once;
|
|
110
|
+
keep links as real `<a href>` / react-email `Button`/`Link` so the plaintext
|
|
111
|
+
extractor produces readable URLs. See `preview-and-render.md` for the render
|
|
112
|
+
machinery.
|