@hogsend/cli 0.2.1 → 0.2.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/cli",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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.4.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, time-based rolling windows + reconcile, entryLimit, the hand-maintained BucketId literal-union typo-safety ritual, and binding journeys to bucket:entered / bucket:left triggers. Buckets wire into BOTH createHogsendClient and createWorker.
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` from `@hogsend/engine` and the condition
20
- helpers / duration helpers from `@hogsend/core`. You never touch engine
21
- internals — the engine owns the registry, the reconcile cron, and the backfill.
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`. `meta.criteria` is the
26
- membership predicate, authored as a `ConditionEval` data tree OR a fluent
27
- `(b) => b.all(...)` builder function. Same condition system journeys use.
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 `createWorker({ buckets })`
39
- (fast-expiry timer task + boot backfill).
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 / event /
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
- - **Keep trigger names typo-safe** (the `BucketId` union + `bucketEntered`/
51
- `bucketLeft` alias ritual and why it exists) → `references/bucket-id-aliases.md`
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. Add the new id to the `BucketId` union the moment you add the bucket. Without
67
- it, a journey can bind to a misspelled alias that silently never fires.
68
- 4. Wire `buckets` into BOTH factories. The client without the worker means no
69
- reconcile/fast-expiry; the worker without the client means an empty registry.
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
- # The `BucketId` union + `bucketEntered` / `bucketLeft` ritual
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 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:
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
- // DON'T — this collapses to `string` and loses all typo-safety.
48
- type BucketId = (typeof buckets)[number]["meta"]["id"];
15
+ wentDormant.entered; // typed `"bucket:entered:went-dormant"`
16
+ wentDormant.left; // typed `"bucket:left:went-dormant"`
49
17
  ```
50
18
 
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.
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
- ## How journeys consume the helpers
23
+ ## Use the typed refs in `trigger.event` / `exitOn`
57
24
 
58
- The helpers return the exact literal, so they drop straight into a journey's
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 { bucketEntered, bucketLeft, Templates } from "./constants/index.js";
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: bucketEntered("went-dormant") },
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: bucketLeft("went-dormant") }],
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
- A typo'd id — `bucketEntered("went-dorment")` is now a compile error.
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
- ## Generic forms vs aliases
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. 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.
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. 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>")`.
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: bucket:entered:power-users
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. Changing it makes a NEW bucket. Keep it in the `BucketId` union (see bucket-id-aliases). |
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 `bucketEntered`/`bucketLeft` helpers (see bucket-id-aliases):
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 { bucketEntered, bucketLeft, Templates } from "./constants/index.js";
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: bucketEntered("power-users") }, // join → start the flow
57
+ trigger: { event: powerUsers.entered }, // join → start the flow
53
58
  entryLimit: "once_per_period",
54
- suppress: hours(24), // required re-entry cool-down
55
- exitOn: [{ event: bucketLeft("power-users") }], // leave → pull them out
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`,