@hogsend/cli 0.2.2 → 0.6.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 +904 -168
- package/dist/bin.js.map +1 -1
- package/package.json +2 -2
- package/skills/hogsend-authoring-buckets/SKILL.md +202 -23
- package/skills/hogsend-authoring-buckets/references/bucket-id-aliases.md +74 -59
- package/skills/hogsend-authoring-buckets/references/bucket-meta.md +19 -4
- package/skills/hogsend-authoring-buckets/references/buckets-vs-journeys.md +52 -8
- package/skills/hogsend-authoring-buckets/references/register-a-bucket.md +58 -24
- package/skills/hogsend-authoring-journeys/SKILL.md +1 -1
- package/skills/hogsend-authoring-journeys/references/journey-meta.md +8 -3
- package/skills/hogsend-authoring-lists/SKILL.md +178 -0
- package/skills/hogsend-cli/SKILL.md +44 -18
- package/skills/hogsend-client-sdk/SKILL.md +185 -0
- package/skills/hogsend-client-sdk/references/api-surface.md +181 -0
- package/src/bin.ts +4 -1
- package/src/commands/campaigns.ts +309 -0
- package/src/commands/contacts.ts +176 -6
- package/src/commands/emails.ts +231 -0
- package/src/commands/events.ts +253 -15
- package/src/commands/index.ts +4 -0
- package/src/commands/types.ts +8 -2
- package/src/lib/config.ts +23 -1
- package/src/lib/http.ts +122 -49
- package/studio/assets/index-BNDE5JtQ.css +1 -0
- package/studio/assets/{index-B49mArEh.js → index-D7Ax_oFF.js} +11 -11
- package/studio/index.html +2 -2
- package/studio/assets/index-CycKZchB.css +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"tsup": "^8.5.1",
|
|
33
33
|
"tsx": "^4.22.4",
|
|
34
34
|
"vitest": "^4.1.7",
|
|
35
|
-
"@hogsend/studio": "^0.
|
|
35
|
+
"@hogsend/studio": "^0.6.0",
|
|
36
36
|
"@repo/typescript-config": "0.0.0"
|
|
37
37
|
},
|
|
38
38
|
"engines": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: hogsend-authoring-buckets
|
|
3
|
-
description: Use when adding or editing a real-time audience bucket in src/buckets/ — defineBucket() with a criteria condition tree,
|
|
3
|
+
description: Use when adding or editing a real-time audience bucket in src/buckets/ — defineBucket() with a criteria condition tree, the typed bucket.entered / bucket.left transition refs used as journey trigger/exitOn, colocated bucket.on("enter"|"leave"|"dwell") reactions (dwell fires from the reconcile cron over the EXISTING population), member access (count/has/members/iterator), time-based rolling windows + reconcile, and entryLimit. Buckets wire into BOTH createHogsendClient and createWorker.
|
|
4
4
|
license: MIT
|
|
5
5
|
metadata:
|
|
6
6
|
author: withSeismic
|
|
@@ -15,45 +15,213 @@ when it stops. Each transition fires `bucket:entered:<id>` / `bucket:left:<id>`
|
|
|
15
15
|
through the same ingestion spine a journey trigger binds to, so buckets are how
|
|
16
16
|
you turn "who is in this audience right now" into "start/stop a flow".
|
|
17
17
|
|
|
18
|
+
A bucket is no longer JUST a membership primitive: the object `defineBucket`
|
|
19
|
+
returns also carries **typed transition refs**, **colocated reactions**, and a
|
|
20
|
+
**member-access surface**. So one bucket file is where you declare the audience,
|
|
21
|
+
attach the behavior, and query it.
|
|
22
|
+
|
|
18
23
|
This skill is for editing a scaffolded app's `src/buckets/` (content only). You
|
|
19
|
-
import `defineBucket` / `DefinedBucket`
|
|
20
|
-
|
|
21
|
-
internals — the engine owns the registry, the reconcile cron,
|
|
24
|
+
import `defineBucket` / `DefinedBucket` (and `sendEmail`, duration helpers) from
|
|
25
|
+
`@hogsend/engine`, and the condition helpers from `@hogsend/core`. You never
|
|
26
|
+
touch engine internals — the engine owns the registry, the reconcile cron, the
|
|
27
|
+
backfill, and the reaction desugar.
|
|
22
28
|
|
|
23
29
|
## Key concepts
|
|
24
30
|
|
|
25
|
-
- **`defineBucket({ meta })`** — returns a `DefinedBucket
|
|
26
|
-
membership predicate, authored as a
|
|
27
|
-
`(b) => b.all(...)` builder function. Same
|
|
31
|
+
- **`defineBucket({ meta })`** — returns a `DefinedBucket<Id>` generic over the
|
|
32
|
+
id literal. `meta.criteria` is the membership predicate, authored as a
|
|
33
|
+
`ConditionEval` data tree OR a fluent `(b) => b.all(...)` builder function. Same
|
|
34
|
+
condition system journeys use.
|
|
35
|
+
- **Typed transition refs** — `bucket.entered` (`` `bucket:entered:${Id}` ``) and
|
|
36
|
+
`bucket.left` (`` `bucket:left:${Id}` ``) are literal-typed off the bucket's own
|
|
37
|
+
id, computed synchronously at `defineBucket` time. Drop them straight into a
|
|
38
|
+
journey's `trigger.event` / `exitOn`. **These are THE way to bind a journey to a
|
|
39
|
+
bucket** — typo-safe by construction, no hand-maintained union.
|
|
40
|
+
- **Colocated reactions** — `bucket.on("enter" | "leave" | "dwell", opts?, handler)`.
|
|
41
|
+
Each desugars to a real durable journey (a `defineJourney` output) tagged with
|
|
42
|
+
`sourceBucketId`, triggered by the bucket's own transition event. The handler
|
|
43
|
+
gets the FULL `JourneyContext` (sleep / when / waitForEvent / guard / history /
|
|
44
|
+
trigger / identify) plus kind-specific read-only extras. `.on()` returns the
|
|
45
|
+
bucket, so calls chain and the reaction ships with the bucket — no separate
|
|
46
|
+
registration.
|
|
47
|
+
- **`dwell`** — fires from the reconcile cron (cron resolution, NOT instant) for
|
|
48
|
+
members who have been CONTINUOUSLY in the bucket for `{ after }` / `{ every }`.
|
|
49
|
+
Its edge over `on("enter") + ctx.sleep` is that it fires for the EXISTING
|
|
50
|
+
population (backfill derives a historical anchor), so on first deploy it reaches
|
|
51
|
+
people already long-resident.
|
|
52
|
+
- **Member access** — `bucket.count()` / `bucket.has(userId)` / `bucket.members({...})`
|
|
53
|
+
/ `bucket.membersIterator()`. Supabase-shaped `{ data, error }` results, keyset
|
|
54
|
+
cursor, hard cap 100. NEVER an unbounded array.
|
|
28
55
|
- **Real-time path** — on every ingested event the engine re-evaluates candidate
|
|
29
56
|
buckets and writes/flips `bucket_memberships` rows, emitting transitions.
|
|
30
57
|
- **Time-based path** — `criteria` with a rolling `within` window (or `maxDwell`)
|
|
31
58
|
can flip membership with NO inbound event; the engine-wide reconcile cron
|
|
32
|
-
sweeps those leaves/joins on a cadence (default every 5 min).
|
|
59
|
+
sweeps those leaves/joins on a cadence (default every 5 min). The same cron runs
|
|
60
|
+
the `dwell` pass.
|
|
33
61
|
- **`entryLimit` / `entryPeriod`** — gate when a RE-join re-emits `bucket:entered`.
|
|
34
|
-
- **The `BucketId` ritual** — a hand-maintained literal union in
|
|
35
|
-
`src/journeys/constants/index.ts` plus `bucketEntered`/`bucketLeft` helpers that
|
|
36
|
-
make a typo'd trigger binding a COMPILE error.
|
|
37
62
|
- **Dual wiring** — buckets thread into BOTH `createHogsendClient({ buckets })`
|
|
38
|
-
(registry, real-time eval, reconcile) AND
|
|
39
|
-
(fast-expiry timer
|
|
63
|
+
(registry, real-time eval, reconcile, reaction registration) AND
|
|
64
|
+
`createWorker({ buckets })` (reaction tasks + fast-expiry timer + boot backfill).
|
|
65
|
+
Reactions ship automatically on `bucket.reactions` — no separate registration —
|
|
66
|
+
and are `ENABLED_BUCKETS`-gated, NOT `ENABLED_JOURNEYS`-gated.
|
|
40
67
|
|
|
41
|
-
Criteria use the same 4-type condition engine (property /
|
|
42
|
-
email_engagement / composite) and the same `days()`/`hours()`/`minutes()`
|
|
68
|
+
Criteria and reaction `opts` use the same 4-type condition engine (property /
|
|
69
|
+
event / email_engagement / composite) and the same `days()`/`hours()`/`minutes()`
|
|
43
70
|
duration helpers as journeys — see the hogsend-conditions skill for operator and
|
|
44
71
|
window semantics.
|
|
45
72
|
|
|
73
|
+
## The shape, end to end
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import { days, defineBucket, sendEmail } from "@hogsend/engine";
|
|
77
|
+
import { Events, Templates } from "../journeys/constants/index.js";
|
|
78
|
+
|
|
79
|
+
export const wentDormant = defineBucket({
|
|
80
|
+
meta: {
|
|
81
|
+
id: "went-dormant",
|
|
82
|
+
name: "Went dormant",
|
|
83
|
+
enabled: true,
|
|
84
|
+
timeBased: true,
|
|
85
|
+
fastExpiry: true,
|
|
86
|
+
criteria: (b) =>
|
|
87
|
+
b.all(
|
|
88
|
+
b.event(Events.APP_ACTIVE).exists(),
|
|
89
|
+
b.event(Events.APP_ACTIVE).within(days(7)).notExists(),
|
|
90
|
+
),
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Colocated reaction — desugars to a durable journey owned by the bucket
|
|
95
|
+
// (grouped under it in Studio via sourceBucketId). `.on()` returns the bucket,
|
|
96
|
+
// so it ships with the bucket; no separate registration.
|
|
97
|
+
wentDormant.on("dwell", { after: days(30) }, async (user) => {
|
|
98
|
+
await sendEmail({
|
|
99
|
+
to: user.email,
|
|
100
|
+
userId: user.id,
|
|
101
|
+
journeyStateId: user.stateId,
|
|
102
|
+
template: Templates.REACTIVATION_FINAL_NUDGE,
|
|
103
|
+
subject: "Still here whenever you're ready",
|
|
104
|
+
journeyName: user.journeyName,
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Typed refs — bind a journey elsewhere to this bucket's transitions:
|
|
109
|
+
// defineJourney({ meta: { trigger: { event: wentDormant.entered }, ... } })
|
|
110
|
+
// defineJourney({ meta: { exitOn: [{ event: wentDormant.left }], ... } })
|
|
111
|
+
```
|
|
112
|
+
|
|
46
113
|
## Task playbooks — load the matching reference
|
|
47
114
|
|
|
48
115
|
- **Author / shape a bucket's `meta`** (id, criteria, time windows, entryLimit,
|
|
49
116
|
dwell, fastExpiry) → `references/bucket-meta.md`
|
|
50
|
-
- **
|
|
51
|
-
`
|
|
117
|
+
- **Bind a journey to a bucket** (the typed `bucket.entered`/`bucket.left` refs;
|
|
118
|
+
the deprecated `BucketId` union + `bucketEntered`/`bucketLeft` legacy; the
|
|
119
|
+
any-bucket generic `Events.BUCKET_ENTERED`/`BUCKET_LEFT`) →
|
|
120
|
+
`references/bucket-id-aliases.md`
|
|
52
121
|
- **Decide bucket vs journey** (membership vs a one-shot durable flow; how
|
|
53
122
|
`bucket:entered`/`bucket:left` drive journeys) → `references/buckets-vs-journeys.md`
|
|
54
123
|
- **Register + wire a bucket** (export from `src/buckets/index.ts`, thread into
|
|
55
|
-
`createHogsendClient` AND `createWorker`, the reconcile cron
|
|
56
|
-
`references/register-a-bucket.md`
|
|
124
|
+
`createHogsendClient` AND `createWorker`, the reconcile cron, reaction
|
|
125
|
+
registration) → `references/register-a-bucket.md`
|
|
126
|
+
|
|
127
|
+
## Colocated reactions — `bucket.on(kind, opts?, handler)`
|
|
128
|
+
|
|
129
|
+
A reaction is NOT an event-emitter listener. Each `.on()` desugars to ONE
|
|
130
|
+
canonical durable journey tagged `sourceBucketId` + `reactionKind`, triggered by
|
|
131
|
+
the bucket's own transition event. Because it IS a `defineJourney` output, it
|
|
132
|
+
inherits the entire enrollment guard stack, the active-state dedup (concurrent
|
|
133
|
+
transitions for one user serialize to a single live run), the durable context,
|
|
134
|
+
and event routing — there is no parallel execution path. A SECOND or DIVERGENT
|
|
135
|
+
reaction to the same transition is just a normal
|
|
136
|
+
`defineJourney({ trigger: { event: bucket.entered } })`, not a second `.on()`.
|
|
137
|
+
|
|
138
|
+
The handler is `(user, ctx)` — the same signature as a journey `run`. `ctx` is
|
|
139
|
+
the full `JourneyContext` PLUS kind-specific read-only extras (built by spread,
|
|
140
|
+
so the engine's canonical ctx is never mutated):
|
|
141
|
+
|
|
142
|
+
| kind | trigger event | ctx extras | options (`opts`) |
|
|
143
|
+
|------|---------------|------------|------------------|
|
|
144
|
+
| `enter` | `bucket.entered` | `entryCount: number`, `isFirstEntry: boolean` | optional `{ firstEntryOnly? }` |
|
|
145
|
+
| `leave` | `bucket.left` | `reason: "criteria" \| "maxDwell" \| "manual"` | optional `{ reason? }` (a reason or array) |
|
|
146
|
+
| `dwell` | cron, internal `bucket:dwell:<id>:<label>` | `dwellCount: number` | **mandatory** `{ after }` XOR `{ every }` |
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
wentDormant
|
|
150
|
+
.on("enter", { firstEntryOnly: true }, async (user, ctx) => {
|
|
151
|
+
// ctx.entryCount / ctx.isFirstEntry; full JourneyContext too
|
|
152
|
+
await ctx.sleep({ duration: hours(1) });
|
|
153
|
+
await sendEmail({ /* ... */ });
|
|
154
|
+
})
|
|
155
|
+
.on("leave", { reason: "criteria" }, async (user, ctx) => {
|
|
156
|
+
// ctx.reason is "criteria" | "maxDwell" | "manual"
|
|
157
|
+
})
|
|
158
|
+
.on("dwell", { after: days(30) }, async (user, ctx) => {
|
|
159
|
+
// ctx.dwellCount — see dwell semantics below
|
|
160
|
+
});
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
`firstEntryOnly` and `reason` are FILTERS, never separate events — they run
|
|
164
|
+
inside `run` AFTER enrollment (a filtered-out transition still writes a short
|
|
165
|
+
active→completed `journeyStates` row). For `dwell` you MUST pass exactly one of
|
|
166
|
+
`after` / `every`; passing neither or both is a `TypeError`.
|
|
167
|
+
|
|
168
|
+
## `dwell` semantics (read before using it)
|
|
169
|
+
|
|
170
|
+
`dwell` is the headline reaction. It fires from the engine-wide reconcile cron
|
|
171
|
+
(`bucketReconcileTask`), so it is **cron-resolution, not instant** — a fire lands
|
|
172
|
+
within the `BUCKET_RECONCILE_CRON` cadence (default `*/5 * * * *`).
|
|
173
|
+
|
|
174
|
+
- **Continuous membership only.** It fires while the user has been CONTINUOUSLY a
|
|
175
|
+
member. A leave-then-rejoin is a NEW membership row with a fresh clock; the
|
|
176
|
+
dwell counter does not carry over.
|
|
177
|
+
- **Fires for the EXISTING population.** This is the entire reason to reach for
|
|
178
|
+
`dwell` over `on("enter") + ctx.sleep(days(30))`. The sleep variant only clocks
|
|
179
|
+
users who enter AFTER you deploy it; `dwell` reads `enteredAt` (and a
|
|
180
|
+
backfill-derived historical `dwellAnchorAt`) over the active set, so on first
|
|
181
|
+
deploy it reaches people already long-resident rather than starting everyone's
|
|
182
|
+
clock at deploy time.
|
|
183
|
+
- **`{ after }`** is one-shot — fires once when the member has dwelt continuously
|
|
184
|
+
for the duration. **`{ every }`** is recurring, coalescing — at most one fire
|
|
185
|
+
per sweep, so a multi-interval outage produces a single catch-up fire, not a
|
|
186
|
+
backlog.
|
|
187
|
+
- **`ctx.dwellCount`** is the elapsed-interval ordinal
|
|
188
|
+
(`floor((now - anchor) / interval)`), NOT a count of actual fires — it is
|
|
189
|
+
gap-stable and equals the number of elapsed periods even across an outage. For
|
|
190
|
+
`after` it is always `1`.
|
|
191
|
+
- **Idempotent** across sweeps (per-membership bookkeeping) and interop-correct
|
|
192
|
+
with `maxDwell` / `fastExpiry` (a member force-left by the TTL/expiry pass is
|
|
193
|
+
excluded). A bucket with `after >= maxDwell` simply never dwells (the member
|
|
194
|
+
leaves first).
|
|
195
|
+
|
|
196
|
+
## Member access — never an unbounded array
|
|
197
|
+
|
|
198
|
+
The bucket object exposes a read surface over its active members. Every method is
|
|
199
|
+
`{ data, error }`-shaped (no throws; failures land in `error`), GDPR-joined to
|
|
200
|
+
live contacts, and hard-capped at 100 rows per page.
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
const { data: total } = await wentDormant.count(); // number | null
|
|
204
|
+
const { data: isMember } = await wentDormant.has(userId); // boolean
|
|
205
|
+
const page = await wentDormant.members({ limit: 50 }); // { data, error, count, cursor }
|
|
206
|
+
for await (const m of wentDormant.membersIterator()) { /* paged internally */ }
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
- `count()` — one authoritative head-count `{ data, error }`.
|
|
210
|
+
- `has(userId)` — O(1) active-membership probe `{ data, error }`.
|
|
211
|
+
- `members({ limit?, cursor? })` — a single page `{ data, error, count, cursor }`.
|
|
212
|
+
Keyset cursor on `id` (opaque UUID order, NOT chronological); `cursor` is `null`
|
|
213
|
+
when exhausted. `count` here is a per-call snapshot that can drift under churn —
|
|
214
|
+
use `count()` for a single authoritative number.
|
|
215
|
+
- `membersIterator({ pageSize? })` — the only full-population walk; bounded
|
|
216
|
+
page-by-page internally (throws on a page error).
|
|
217
|
+
|
|
218
|
+
## Studio grouping
|
|
219
|
+
|
|
220
|
+
A generated reaction carries `sourceBucketId` on its journey meta, so the admin
|
|
221
|
+
bucket-detail view groups it UNDER its bucket, tagged `owned: true`. A journey
|
|
222
|
+
that binds externally via `bucket.entered` / `bucket.left` (or the generic
|
|
223
|
+
`Events.BUCKET_ENTERED`/`BUCKET_LEFT`) shows up there too, tagged `owned: false`.
|
|
224
|
+
You don't wire any of this — it falls out of the desugar tagging.
|
|
57
225
|
|
|
58
226
|
## Golden rules
|
|
59
227
|
|
|
@@ -63,7 +231,18 @@ window semantics.
|
|
|
63
231
|
valid (time-bounded) anchor.
|
|
64
232
|
2. Never reference a `bucket:*` event name inside `criteria` — it is rejected at
|
|
65
233
|
registration so transition rows can never satisfy a predicate.
|
|
66
|
-
3.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
234
|
+
3. Bind journeys with the typed refs `bucket.entered` / `bucket.left` — they are
|
|
235
|
+
literal-typed off the bucket id and typo-safe by construction. The
|
|
236
|
+
`bucketEntered("id")`/`bucketLeft("id")` string helpers and the hand-maintained
|
|
237
|
+
`BucketId` union are DEPRECATED (kept one release for back-compat). For an
|
|
238
|
+
any-bucket binding, `Events.BUCKET_ENTERED` / `Events.BUCKET_LEFT` remain the
|
|
239
|
+
sanctioned generic surface (not deprecated).
|
|
240
|
+
4. Reach for `dwell` only when you need the EXISTING population and
|
|
241
|
+
cron-resolution timing is acceptable; otherwise `on("enter") + ctx.sleep` is a
|
|
242
|
+
simpler per-user durable timer.
|
|
243
|
+
5. Member access is read-only and paged — never load all members into an array;
|
|
244
|
+
use `count()`/`has()` for probes and the iterator for a full walk.
|
|
245
|
+
6. Wire `buckets` into BOTH factories. The client without the worker means no
|
|
246
|
+
reconcile/fast-expiry/dwell and no reaction tasks; the worker without the
|
|
247
|
+
client means an empty registry. Reactions ship on `bucket.reactions`
|
|
248
|
+
automatically and are `ENABLED_BUCKETS`-gated.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Binding a journey to a bucket — typed refs (and the deprecated ritual)
|
|
2
2
|
|
|
3
3
|
When a user joins or leaves a bucket the engine emits a per-bucket ALIAS event:
|
|
4
4
|
`bucket:entered:<id>` / `bucket:left:<id>` (e.g. `bucket:entered:power-users`).
|
|
@@ -6,62 +6,29 @@ Journeys bind to those alias strings via `trigger.event`. The problem: a
|
|
|
6
6
|
journey's `trigger.event` is typed `string`, so a misspelled alias compiles fine
|
|
7
7
|
and the journey just silently never fires.
|
|
8
8
|
|
|
9
|
-
The fix is a hand-maintained
|
|
10
|
-
|
|
11
|
-
|
|
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:
|
|
9
|
+
The fix is no longer a hand-maintained union — it is built into the bucket
|
|
10
|
+
object. `defineBucket` is generic over the id literal (`DefinedBucket<Id>`), so
|
|
11
|
+
the bucket exposes two **typed transition refs** computed synchronously at
|
|
12
|
+
`defineBucket` time:
|
|
45
13
|
|
|
46
14
|
```ts
|
|
47
|
-
//
|
|
48
|
-
|
|
15
|
+
wentDormant.entered; // typed `"bucket:entered:went-dormant"`
|
|
16
|
+
wentDormant.left; // typed `"bucket:left:went-dormant"`
|
|
49
17
|
```
|
|
50
18
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
source of truth precisely because it can't be widened.
|
|
19
|
+
These are literal-typed off the bucket's own `meta.id`, so binding to them is
|
|
20
|
+
typo-safe by construction — there is nothing to keep in sync. This is THE way to
|
|
21
|
+
bind a journey to a bucket.
|
|
55
22
|
|
|
56
|
-
##
|
|
23
|
+
## Use the typed refs in `trigger.event` / `exitOn`
|
|
57
24
|
|
|
58
|
-
|
|
59
|
-
`trigger.event` (and `exitOn`):
|
|
25
|
+
Import the bucket and read `.entered` / `.left` directly:
|
|
60
26
|
|
|
61
27
|
```ts
|
|
62
28
|
import { hours } from "@hogsend/core";
|
|
63
29
|
import { defineJourney, sendEmail } from "@hogsend/engine";
|
|
64
|
-
import {
|
|
30
|
+
import { wentDormant } from "../buckets/went-dormant.js"; // leaf module — see below
|
|
31
|
+
import { Templates } from "./constants/index.js";
|
|
65
32
|
|
|
66
33
|
export const winback = defineJourney({
|
|
67
34
|
meta: {
|
|
@@ -69,12 +36,12 @@ export const winback = defineJourney({
|
|
|
69
36
|
name: "Win-back",
|
|
70
37
|
enabled: true,
|
|
71
38
|
// Enroll the moment a user lands in the went-dormant bucket.
|
|
72
|
-
trigger: { event:
|
|
39
|
+
trigger: { event: wentDormant.entered },
|
|
73
40
|
entryLimit: "once_per_period",
|
|
74
41
|
// `suppress` is REQUIRED on every JourneyMeta — the re-entry cool-down.
|
|
75
42
|
suppress: hours(24),
|
|
76
43
|
// Pull them out the instant they re-activate (leave the bucket).
|
|
77
|
-
exitOn: [{ event:
|
|
44
|
+
exitOn: [{ event: wentDormant.left }],
|
|
78
45
|
},
|
|
79
46
|
run: async (user) => {
|
|
80
47
|
await sendEmail({
|
|
@@ -89,9 +56,23 @@ export const winback = defineJourney({
|
|
|
89
56
|
});
|
|
90
57
|
```
|
|
91
58
|
|
|
92
|
-
|
|
59
|
+
The refs are byte-identical to the alias the engine emits, so this is a drop-in
|
|
60
|
+
for the old helper return value — only typo-safer. A typo'd ref —
|
|
61
|
+
`wentDormant.entred` — is now a compile error.
|
|
62
|
+
|
|
63
|
+
## Import the bucket from its LEAF module, not the barrel
|
|
93
64
|
|
|
94
|
-
|
|
65
|
+
When you read `wentDormant.left` at module-eval inside a top-level
|
|
66
|
+
`defineJourney({ exitOn: [{ event: wentDormant.left }] })`, importing the bucket
|
|
67
|
+
from the `../buckets/index.js` barrel creates a real ESM cycle
|
|
68
|
+
(`journeys/index → this-journey → buckets/index → went-dormant → journeys/constants`).
|
|
69
|
+
Import the bucket from its **leaf module** (`../buckets/went-dormant.js`)
|
|
70
|
+
instead — that keeps the whole bucket barrel out of the journey-barrel cycle. The
|
|
71
|
+
refs themselves are safe to read at module-eval because they are pure string
|
|
72
|
+
concatenation of `meta.id` with no live cross-module value binding, but the leaf
|
|
73
|
+
import keeps the cycle from forming at all.
|
|
74
|
+
|
|
75
|
+
## Generic forms vs per-bucket refs
|
|
95
76
|
|
|
96
77
|
The constants file also defines the GENERIC events:
|
|
97
78
|
|
|
@@ -103,15 +84,49 @@ export const Events = {
|
|
|
103
84
|
} as const;
|
|
104
85
|
```
|
|
105
86
|
|
|
106
|
-
These fire for ANY bucket
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
87
|
+
These fire for ANY bucket and are the **sanctioned generic-binding surface — NOT
|
|
88
|
+
deprecated**. The engine only emits the generic `bucket:entered` / `bucket:left`
|
|
89
|
+
when a journey actually binds to it (otherwise it's not written at all). The rule:
|
|
90
|
+
|
|
91
|
+
- **Per-bucket** (this audience specifically) → `bucket.entered` / `bucket.left`.
|
|
92
|
+
- **Any-bucket** (any transition, whichever bucket) → `Events.BUCKET_ENTERED` /
|
|
93
|
+
`Events.BUCKET_LEFT`.
|
|
94
|
+
|
|
95
|
+
## DEPRECATED — the `BucketId` union + `bucketEntered`/`bucketLeft` helpers
|
|
96
|
+
|
|
97
|
+
The old path was a hand-maintained literal union plus two helper functions in
|
|
98
|
+
`src/journeys/constants/`:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
/** @deprecated Use the typed ref `bucket.entered` / `bucket.left`. */
|
|
102
|
+
export type BucketId = "power-users" | "went-dormant";
|
|
103
|
+
/** @deprecated Use `bucket.entered` (e.g. `wentDormant.entered`). */
|
|
104
|
+
export const bucketEntered = <T extends BucketId>(id: T) =>
|
|
105
|
+
`bucket:entered:${id}` as const;
|
|
106
|
+
/** @deprecated Use `bucket.left` (e.g. `wentDormant.left`). */
|
|
107
|
+
export const bucketLeft = <T extends BucketId>(id: T) =>
|
|
108
|
+
`bucket:left:${id}` as const;
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
These are **deprecated and kept for ONE release for back-compat, then removed.**
|
|
112
|
+
They still work and return the byte-identical alias, but they require you to
|
|
113
|
+
hand-maintain the `BucketId` union in lockstep with the `buckets` array — exactly
|
|
114
|
+
the chore the typed refs eliminate. Do NOT reach for them in new code; migrate
|
|
115
|
+
existing `bucketEntered("id")` / `bucketLeft("id")` bindings to `bucket.entered` /
|
|
116
|
+
`bucket.left` (importing the bucket from its leaf module).
|
|
117
|
+
|
|
118
|
+
> Why the union could never just be derived from the array:
|
|
119
|
+
> `defineBucket` widens `meta.id` to `string` on the base `DefinedBucket` type, so
|
|
120
|
+
> `(typeof buckets)[number]["meta"]["id"]` collapses to `string` and loses all
|
|
121
|
+
> typo-safety. The typed refs sidestep this entirely by keeping the `Id` literal
|
|
122
|
+
> on `DefinedBucket<Id>` and deriving the ref type from it — provided the consumer
|
|
123
|
+
> doesn't re-widen the array with a `DefinedBucket[]` annotation (see
|
|
124
|
+
> register-a-bucket).
|
|
111
125
|
|
|
112
126
|
## The checklist when adding a bucket
|
|
113
127
|
|
|
114
128
|
1. Add the `defineBucket(...)` in `src/buckets/`.
|
|
115
|
-
2.
|
|
116
|
-
3.
|
|
117
|
-
|
|
129
|
+
2. Register it in `src/buckets/index.ts` (see register-a-bucket).
|
|
130
|
+
3. Bind journeys with the typed refs `bucket.entered` / `bucket.left` (import the
|
|
131
|
+
bucket from its leaf module), or `Events.BUCKET_ENTERED`/`BUCKET_LEFT` for an
|
|
132
|
+
any-bucket binding.
|
|
@@ -5,13 +5,12 @@ declaration — the engine derives everything (registry indexes, reconcile
|
|
|
5
5
|
behavior, Studio size) from it. This is the field-by-field guide.
|
|
6
6
|
|
|
7
7
|
```ts
|
|
8
|
-
import { defineBucket } from "@hogsend/engine";
|
|
9
|
-
import { days } from "@hogsend/core";
|
|
8
|
+
import { days, defineBucket } from "@hogsend/engine";
|
|
10
9
|
import { Events } from "../journeys/constants/index.js";
|
|
11
10
|
|
|
12
11
|
export const powerUsers = defineBucket({
|
|
13
12
|
meta: {
|
|
14
|
-
id: "power-users", // also the alias suffix
|
|
13
|
+
id: "power-users", // also the alias suffix → powerUsers.entered === "bucket:entered:power-users"
|
|
15
14
|
name: "Power users",
|
|
16
15
|
description: "Used the key feature 10+ times in the last 30 days.",
|
|
17
16
|
enabled: true,
|
|
@@ -34,7 +33,7 @@ export const powerUsers = defineBucket({
|
|
|
34
33
|
|
|
35
34
|
| Field | Type | Notes |
|
|
36
35
|
|-------|------|-------|
|
|
37
|
-
| `id` | `string` (required) | Stable identity AND the alias suffix
|
|
36
|
+
| `id` | `string` (required) | Stable identity AND the alias suffix — also the literal baked into the typed refs `bucket.entered` / `bucket.left` (see bucket-id-aliases). Changing it makes a NEW bucket. No hand-maintained union to update anymore. |
|
|
38
37
|
| `name` | `string` (required) | Human label, surfaced in Studio + the emitted `bucketName`. |
|
|
39
38
|
| `description` | `string?` | Free text. |
|
|
40
39
|
| `enabled` | `boolean` (required) | Static load-time on/off (guard #1), mirrors a journey's `enabled`. A disabled bucket is not registered. |
|
|
@@ -51,6 +50,22 @@ export const powerUsers = defineBucket({
|
|
|
51
50
|
| `syncToPostHog` | `boolean?` | Mirror membership to a PostHog person property on join/leave. Off by default; no-op without `POSTHOG_API_KEY`. |
|
|
52
51
|
| `postHogPropertyKey` | `string?` | Override the synced property name (default `hogsend_bucket_<id>`). |
|
|
53
52
|
|
|
53
|
+
## Beyond `meta` — the bucket object surface
|
|
54
|
+
|
|
55
|
+
`defineBucket` returns a `DefinedBucket<Id>` generic over the id literal. Besides
|
|
56
|
+
`meta`, the returned object carries everything you author or query AGAINST the
|
|
57
|
+
bucket — none of it lives in `meta`:
|
|
58
|
+
|
|
59
|
+
| On the bucket object | What it is |
|
|
60
|
+
|----------------------|------------|
|
|
61
|
+
| `bucket.entered` / `bucket.left` | Typed transition refs (`` `bucket:entered:${Id}` `` / `` `bucket:left:${Id}` ``), literal-typed off `meta.id`, derived synchronously at `defineBucket` time. Use as a journey `trigger.event` / `exitOn` (see bucket-id-aliases). |
|
|
62
|
+
| `bucket.on(kind, opts?, handler)` | Colocated reaction — `"enter"` / `"leave"` / `"dwell"`. Each desugars to a real durable journey tagged `sourceBucketId`, pushed onto `bucket.reactions`. Returns the bucket, so calls chain. See the main SKILL for kinds, ctx extras, and dwell semantics. |
|
|
63
|
+
| `bucket.reactions` | The array of generated reaction journeys (read by the client + worker). Ships automatically — you never register it. |
|
|
64
|
+
| `bucket.count()` / `has(userId)` / `members({...})` / `membersIterator()` | Member-access read surface. `{ data, error }`-shaped, GDPR-joined, paged (hard cap 100), never an unbounded array. |
|
|
65
|
+
|
|
66
|
+
So `meta` shapes WHO is in the bucket and HOW membership flips; the rest of the
|
|
67
|
+
object is how you BIND to it, attach behavior, and read it.
|
|
68
|
+
|
|
54
69
|
## `criteria` — two authoring forms
|
|
55
70
|
|
|
56
71
|
Both forms produce the SAME `ConditionEval` data. The builder runs ONCE at
|
|
@@ -12,7 +12,9 @@ answer different questions:
|
|
|
12
12
|
a beginning and an end, durable state, and enrollment guards.
|
|
13
13
|
|
|
14
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.
|
|
15
|
+
can trigger (or exit) on that event. That's the whole integration. A bucket can
|
|
16
|
+
also carry its OWN behavior inline via `.on()` reactions (which themselves
|
|
17
|
+
desugar to journeys — see below), and you can QUERY a bucket's members directly.
|
|
16
18
|
|
|
17
19
|
## Pick a bucket when…
|
|
18
20
|
|
|
@@ -23,7 +25,8 @@ can trigger (or exit) on that event. That's the whole integration.
|
|
|
23
25
|
cron owns those leaves; a journey cannot observe "nothing happened".
|
|
24
26
|
- You want to reuse the same audience for MANY downstream flows. One bucket, N
|
|
25
27
|
journeys binding to its transitions.
|
|
26
|
-
- You want the audience size visible in Studio
|
|
28
|
+
- You want the audience size visible in Studio, or you want to query membership
|
|
29
|
+
in code (`count` / `has` / `members`).
|
|
27
30
|
|
|
28
31
|
## Pick a journey when…
|
|
29
32
|
|
|
@@ -37,22 +40,24 @@ can trigger (or exit) on that event. That's the whole integration.
|
|
|
37
40
|
On a real join the engine emits `bucket:entered:<id>`; on a leave,
|
|
38
41
|
`bucket:left:<id>`. Both flow through the SAME `ingestEvent` pipeline a normal
|
|
39
42
|
event does, so journeys route on them exactly like any other trigger. Bind with
|
|
40
|
-
the typed `
|
|
43
|
+
the bucket's typed transition refs `bucket.entered` / `bucket.left` (literal-typed
|
|
44
|
+
off the id — see bucket-id-aliases), importing the bucket from its leaf module:
|
|
41
45
|
|
|
42
46
|
```ts
|
|
43
47
|
import { days, hours } from "@hogsend/core";
|
|
44
48
|
import { defineJourney, sendEmail } from "@hogsend/engine";
|
|
45
|
-
import {
|
|
49
|
+
import { powerUsers } from "../buckets/power-users.js"; // leaf module, not the barrel
|
|
50
|
+
import { Templates } from "./constants/index.js";
|
|
46
51
|
|
|
47
52
|
export const powerUserOnboarding = defineJourney({
|
|
48
53
|
meta: {
|
|
49
54
|
id: "power-user-onboarding",
|
|
50
55
|
name: "Power-user onboarding",
|
|
51
56
|
enabled: true,
|
|
52
|
-
trigger: { event:
|
|
57
|
+
trigger: { event: powerUsers.entered }, // join → start the flow
|
|
53
58
|
entryLimit: "once_per_period",
|
|
54
|
-
suppress: hours(24),
|
|
55
|
-
exitOn: [{ event:
|
|
59
|
+
suppress: hours(24), // required re-entry cool-down
|
|
60
|
+
exitOn: [{ event: powerUsers.left }], // leave → pull them out
|
|
56
61
|
},
|
|
57
62
|
run: async (user, ctx) => {
|
|
58
63
|
await sendEmail({
|
|
@@ -69,6 +74,44 @@ export const powerUserOnboarding = defineJourney({
|
|
|
69
74
|
});
|
|
70
75
|
```
|
|
71
76
|
|
|
77
|
+
## Inline reaction vs a separate journey
|
|
78
|
+
|
|
79
|
+
A bucket can also carry behavior INLINE with `bucket.on("enter" | "leave" |
|
|
80
|
+
"dwell", ...)`. A reaction is NOT a lightweight listener — it **desugars to a real
|
|
81
|
+
durable journey** tagged `sourceBucketId`, triggered by the bucket's own
|
|
82
|
+
transition event, inheriting the full enrollment-guard stack and durable context.
|
|
83
|
+
So the choice is about colocation, not capability:
|
|
84
|
+
|
|
85
|
+
- **One canonical behavior per transition** that belongs WITH the audience →
|
|
86
|
+
colocate it as `bucket.on(...)`. It ships with the bucket and groups under it in
|
|
87
|
+
Studio.
|
|
88
|
+
- **A SECOND or DIVERGENT behavior on the same transition** → write a normal
|
|
89
|
+
`defineJourney({ trigger: { event: bucket.entered } })`. There is one canonical
|
|
90
|
+
reaction per transition; everything beyond that is just another journey binding
|
|
91
|
+
to the typed ref. Don't stack multiple `.on("enter")` calls expecting an
|
|
92
|
+
emitter-style fan-out — that's what a separate journey is for.
|
|
93
|
+
|
|
94
|
+
`dwell` has no journey equivalent: it fires from the reconcile cron over the
|
|
95
|
+
EXISTING continuously-resident population, which `on("enter") + ctx.sleep` (a
|
|
96
|
+
per-user durable timer that only clocks future entrants) cannot do. See the main
|
|
97
|
+
SKILL for dwell semantics.
|
|
98
|
+
|
|
99
|
+
## Querying a bucket's members
|
|
100
|
+
|
|
101
|
+
A bucket is also a read surface — you don't need a journey to ask who's in it:
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
const { data: total } = await powerUsers.count(); // number | null
|
|
105
|
+
const { data: isMember } = await powerUsers.has(userId); // boolean
|
|
106
|
+
const page = await powerUsers.members({ limit: 50 }); // { data, error, count, cursor }
|
|
107
|
+
for await (const m of powerUsers.membersIterator()) { /* paged internally */ }
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
All results are `{ data, error }`-shaped, GDPR-joined to live contacts, and
|
|
111
|
+
paged (hard cap 100) — never an unbounded array. Reach for these from a workflow,
|
|
112
|
+
a custom route, or a script; reach for a journey when you need per-user durable
|
|
113
|
+
control flow instead.
|
|
114
|
+
|
|
72
115
|
## The re-emit / debounce knobs work TOGETHER
|
|
73
116
|
|
|
74
117
|
A bucket and the journeys it drives both have entry policies; tune them so
|
|
@@ -81,7 +124,8 @@ oscillation doesn't spam:
|
|
|
81
124
|
`bucket:entered` (e.g. `"once_per_period"` with a 7-day `entryPeriod` won't
|
|
82
125
|
re-emit a join within a week of the prior leave).
|
|
83
126
|
- **Journey `entryLimit` / `suppress`** are the journey's own re-entry guard on
|
|
84
|
-
top of that.
|
|
127
|
+
top of that. (Generated reactions intentionally use `entryLimit:"unlimited"` +
|
|
128
|
+
`suppress:{seconds:0}` — re-entry is a filter there, not a cool-down.)
|
|
85
129
|
|
|
86
130
|
Rule of thumb: shape the audience on the bucket (`criteria`, `minDwell`,
|
|
87
131
|
`entryLimit`), shape the messaging cadence on the journey (`suppress`,
|