@hogsend/engine 0.5.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/engine",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -37,11 +37,11 @@
37
37
  "resend": "^6.12.3",
38
38
  "winston": "^3.19.0",
39
39
  "zod": "^4.4.3",
40
- "@hogsend/core": "^0.5.0",
41
- "@hogsend/db": "^0.5.0",
42
- "@hogsend/email": "^0.5.0",
43
- "@hogsend/plugin-posthog": "^0.5.0",
44
- "@hogsend/plugin-resend": "^0.5.0"
40
+ "@hogsend/core": "^0.6.0",
41
+ "@hogsend/db": "^0.6.0",
42
+ "@hogsend/email": "^0.6.0",
43
+ "@hogsend/plugin-posthog": "^0.6.0",
44
+ "@hogsend/plugin-resend": "^0.6.0"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@types/node": "^22.15.3",
@@ -0,0 +1,213 @@
1
+ import { bucketMemberships, contacts, type Database } from "@hogsend/db";
2
+ import { and, count as countFn, eq, gt, isNull } from "drizzle-orm";
3
+ import { getDb } from "../lib/db.js";
4
+
5
+ /**
6
+ * Hard cap on a single page — mirrors the admin `listMembersRoute` `z.max(100)`.
7
+ * Member access is NEVER an unbounded array; even the iterator pages internally.
8
+ */
9
+ const MAX_PAGE = 100;
10
+ /** Default page size when the caller does not specify a limit. */
11
+ const DEFAULT_PAGE = 50;
12
+
13
+ /** A serialized active-membership row returned by `members()` / the iterator. */
14
+ export interface BucketMemberRow {
15
+ id: string;
16
+ userId: string;
17
+ userEmail: string | null;
18
+ enteredAt: string;
19
+ entryCount: number;
20
+ }
21
+
22
+ /** Supabase-shaped paged result — no throw; failures land in `error`. */
23
+ export interface MembersResult {
24
+ data: BucketMemberRow[];
25
+ error: Error | null;
26
+ /**
27
+ * Per-call snapshot total (active, non-deleted, joined to a live contact).
28
+ * NOT a consistent paginated total — under churn it can drift page-to-page.
29
+ * The keyset cursor itself is churn-safe; use `count()` for one authoritative
30
+ * number.
31
+ */
32
+ count: number | null;
33
+ /** Keyset continuation (last row `id`); `null` when the page is exhausted. */
34
+ cursor: string | null;
35
+ }
36
+
37
+ export interface BucketAccessor {
38
+ count(): Promise<{ data: number | null; error: Error | null }>;
39
+ has(userId: string): Promise<{ data: boolean; error: Error | null }>;
40
+ members(opts?: { limit?: number; cursor?: string }): Promise<MembersResult>;
41
+ membersIterator(opts?: {
42
+ pageSize?: number;
43
+ }): AsyncIterableIterator<BucketMemberRow>;
44
+ }
45
+
46
+ function toError(err: unknown): Error {
47
+ return err instanceof Error ? err : new Error(String(err));
48
+ }
49
+
50
+ /**
51
+ * Build the per-bucket member-access surface (`count`/`has`/`members`/iterator).
52
+ *
53
+ * The accessor is constructed at module load (`defineBucket` time), before any
54
+ * container exists, so it defaults to `getDb()` — the same singleton the
55
+ * desugared reaction journeys run on. To honor `overrides.db` in tests, the
56
+ * container re-binds the accessors with a `dbResolver` that returns its own
57
+ * `db`; the `getDb()` default bypasses the container.
58
+ *
59
+ * Every query `innerJoin`s `contacts` on `externalId` and filters
60
+ * `isNull(deletedAt)` on both tables — GDPR parity with every reconcile/admin
61
+ * query. No method throws (except the iterator on a page error); failures are
62
+ * carried in the result's `error`.
63
+ */
64
+ export function createBucketAccessor(
65
+ bucketId: string,
66
+ dbResolver: () => Database = getDb,
67
+ ): BucketAccessor {
68
+ async function count(): Promise<{
69
+ data: number | null;
70
+ error: Error | null;
71
+ }> {
72
+ try {
73
+ const db = dbResolver();
74
+ const rows = await db
75
+ .select({ value: countFn() })
76
+ .from(bucketMemberships)
77
+ .innerJoin(contacts, eq(contacts.externalId, bucketMemberships.userId))
78
+ .where(
79
+ and(
80
+ eq(bucketMemberships.bucketId, bucketId),
81
+ eq(bucketMemberships.status, "active"),
82
+ isNull(bucketMemberships.deletedAt),
83
+ isNull(contacts.deletedAt),
84
+ ),
85
+ );
86
+ return { data: rows[0]?.value ?? 0, error: null };
87
+ } catch (err) {
88
+ return { data: null, error: toError(err) };
89
+ }
90
+ }
91
+
92
+ async function has(
93
+ userId: string,
94
+ ): Promise<{ data: boolean; error: Error | null }> {
95
+ try {
96
+ const db = dbResolver();
97
+ // O(1) probe on the partial active unique index (uq_user_bucket_active).
98
+ const rows = await db
99
+ .select({ id: bucketMemberships.id })
100
+ .from(bucketMemberships)
101
+ .innerJoin(contacts, eq(contacts.externalId, bucketMemberships.userId))
102
+ .where(
103
+ and(
104
+ eq(bucketMemberships.bucketId, bucketId),
105
+ eq(bucketMemberships.userId, userId),
106
+ eq(bucketMemberships.status, "active"),
107
+ isNull(bucketMemberships.deletedAt),
108
+ isNull(contacts.deletedAt),
109
+ ),
110
+ )
111
+ .limit(1);
112
+ return { data: rows.length > 0, error: null };
113
+ } catch (err) {
114
+ return { data: false, error: toError(err) };
115
+ }
116
+ }
117
+
118
+ async function members(opts?: {
119
+ limit?: number;
120
+ cursor?: string;
121
+ }): Promise<MembersResult> {
122
+ const limit = Math.min(opts?.limit ?? DEFAULT_PAGE, MAX_PAGE);
123
+ try {
124
+ const db = dbResolver();
125
+ const conditions = [
126
+ eq(bucketMemberships.bucketId, bucketId),
127
+ eq(bucketMemberships.status, "active"),
128
+ isNull(bucketMemberships.deletedAt),
129
+ isNull(contacts.deletedAt),
130
+ ];
131
+ // Keyset cursor on `id` (UUID, unique, stable — NOT enteredAt, which ties
132
+ // on defaultNow). Opaque (UUID asc) order, not chronological.
133
+ if (opts?.cursor) {
134
+ conditions.push(gt(bucketMemberships.id, opts.cursor));
135
+ }
136
+
137
+ const [rows, totalRows] = await Promise.all([
138
+ db
139
+ .select({
140
+ id: bucketMemberships.id,
141
+ userId: bucketMemberships.userId,
142
+ userEmail: bucketMemberships.userEmail,
143
+ enteredAt: bucketMemberships.enteredAt,
144
+ entryCount: bucketMemberships.entryCount,
145
+ })
146
+ .from(bucketMemberships)
147
+ .innerJoin(
148
+ contacts,
149
+ eq(contacts.externalId, bucketMemberships.userId),
150
+ )
151
+ .where(and(...conditions))
152
+ .orderBy(bucketMemberships.id)
153
+ // +1 peek to detect a next page.
154
+ .limit(limit + 1),
155
+ db
156
+ .select({ value: countFn() })
157
+ .from(bucketMemberships)
158
+ .innerJoin(
159
+ contacts,
160
+ eq(contacts.externalId, bucketMemberships.userId),
161
+ )
162
+ .where(
163
+ and(
164
+ eq(bucketMemberships.bucketId, bucketId),
165
+ eq(bucketMemberships.status, "active"),
166
+ isNull(bucketMemberships.deletedAt),
167
+ isNull(contacts.deletedAt),
168
+ ),
169
+ ),
170
+ ]);
171
+
172
+ const hasMore = rows.length > limit;
173
+ const page = hasMore ? rows.slice(0, limit) : rows;
174
+ const data: BucketMemberRow[] = page.map((r) => ({
175
+ id: r.id,
176
+ userId: r.userId,
177
+ userEmail: r.userEmail,
178
+ enteredAt: r.enteredAt.toISOString(),
179
+ entryCount: r.entryCount,
180
+ }));
181
+ const cursor = hasMore ? (page[page.length - 1]?.id ?? null) : null;
182
+
183
+ return {
184
+ data,
185
+ error: null,
186
+ count: totalRows[0]?.value ?? 0,
187
+ cursor,
188
+ };
189
+ } catch (err) {
190
+ return { data: [], error: toError(err), count: null, cursor: null };
191
+ }
192
+ }
193
+
194
+ async function* membersIterator(opts?: {
195
+ pageSize?: number;
196
+ }): AsyncIterableIterator<BucketMemberRow> {
197
+ const pageSize = Math.min(opts?.pageSize ?? DEFAULT_PAGE, MAX_PAGE);
198
+ let cursor: string | null | undefined;
199
+ // The only full-population traversal — bounded page-by-page via members().
200
+ while (true) {
201
+ const page = await members({
202
+ limit: pageSize,
203
+ cursor: cursor ?? undefined,
204
+ });
205
+ if (page.error) throw page.error;
206
+ for (const row of page.data) yield row;
207
+ if (!page.cursor) break;
208
+ cursor = page.cursor;
209
+ }
210
+ }
211
+
212
+ return { count, has, members, membersIterator };
213
+ }
@@ -0,0 +1,225 @@
1
+ import type { DurationObject } from "@hogsend/core";
2
+ import { durationToMs } from "@hogsend/core";
3
+ import type {
4
+ JourneyContext,
5
+ JourneyMeta,
6
+ JourneyUser,
7
+ } from "@hogsend/core/types";
8
+ import {
9
+ type DefinedJourney,
10
+ defineJourney,
11
+ } from "../journeys/define-journey.js";
12
+ import type { DefinedBucket } from "./define-bucket.js";
13
+
14
+ /**
15
+ * Why a reaction reaches for THIS leave reason. Carried on the emitted
16
+ * `bucket:left:<id>` event properties and surfaced to a `leave` handler as
17
+ * `ctx.reason`. `"manual"` is reserved for a future force-leave path.
18
+ */
19
+ export type BucketLeaveReason = "criteria" | "maxDwell" | "manual";
20
+
21
+ /** `bucket.on("enter", opts?, handler)` options. */
22
+ export interface EnterOptions {
23
+ /**
24
+ * Only run the handler on the user's FIRST entry to this bucket (re-entries
25
+ * are skipped). Re-entry is a FILTER, never a separate event — the filter runs
26
+ * inside `run` AFTER enrollment (a filtered-out entry still writes a short
27
+ * active→completed `journeyStates` row).
28
+ */
29
+ firstEntryOnly?: boolean;
30
+ }
31
+
32
+ /** `bucket.on("leave", opts?, handler)` options. */
33
+ export interface LeaveOptions {
34
+ /**
35
+ * Only run the handler when the leave matches this reason (or one of these
36
+ * reasons). Filter runs inside `run` AFTER enrollment.
37
+ */
38
+ reason?: BucketLeaveReason | BucketLeaveReason[];
39
+ }
40
+
41
+ /**
42
+ * `bucket.on("dwell", opts, handler)` options — exactly one of `after`/`every`.
43
+ * - `after`: one-shot, fires once when the member has dwelt continuously for
44
+ * the duration.
45
+ * - `every`: recurring, fires (coalescing) once per elapsed interval.
46
+ */
47
+ export type DwellOptions =
48
+ | { after: DurationObject; every?: never }
49
+ | { every: DurationObject; after?: never };
50
+
51
+ /**
52
+ * Reaction-specific read-only extras layered onto the canonical
53
+ * `JourneyContext` for the handler, discriminated by the reaction kind.
54
+ */
55
+ export type ReactionExtras<K> = K extends "enter"
56
+ ? { entryCount: number; isFirstEntry: boolean }
57
+ : K extends "leave"
58
+ ? { reason: BucketLeaveReason }
59
+ : { dwellCount: number };
60
+
61
+ /** The ctx a reaction handler receives: full JourneyContext + kind extras. */
62
+ export type BucketReactionCtx<K extends "enter" | "leave" | "dwell"> =
63
+ JourneyContext & ReactionExtras<K>;
64
+
65
+ /** A bucket-reaction handler — same `(user, ctx)` shape as a journey `run`. */
66
+ export type BucketOnHandler<K extends "enter" | "leave" | "dwell"> = (
67
+ user: JourneyUser,
68
+ ctx: BucketReactionCtx<K>,
69
+ ) => Promise<void>;
70
+
71
+ /** Coerce a single value or array to an array. */
72
+ export function asArray<T>(value: T | T[]): T[] {
73
+ return Array.isArray(value) ? value : [value];
74
+ }
75
+
76
+ /**
77
+ * Stable, schedule-unique label for a dwell reaction: `after-<ms>` / `every-<ms>`.
78
+ * Hatchet keys workflows by `journey-${id}`, so two dwell reactions on one bucket
79
+ * (one `after`, one `every`) get distinct, boot-stable ids/events.
80
+ */
81
+ export function dwellLabel(opts: DwellOptions): string {
82
+ return opts.after != null
83
+ ? `after-${durationToMs(opts.after)}`
84
+ : `every-${durationToMs(opts.every)}`;
85
+ }
86
+
87
+ /** The schema persisted on the reaction meta (read by the dwell cron). */
88
+ export function parseDwellSchedule(opts: DwellOptions): {
89
+ label: string;
90
+ after?: number;
91
+ every?: number;
92
+ } {
93
+ return opts.after != null
94
+ ? { label: dwellLabel(opts), after: durationToMs(opts.after) }
95
+ : { label: dwellLabel(opts), every: durationToMs(opts.every) };
96
+ }
97
+
98
+ /** Derive the generated reaction journey id from the bucket id + kind. */
99
+ export function reactionJourneyId(
100
+ bucketId: string,
101
+ kind: "enter" | "leave" | "dwell",
102
+ opts: EnterOptions | LeaveOptions | DwellOptions | undefined,
103
+ ): string {
104
+ return kind === "dwell"
105
+ ? `bucket-${bucketId}-on-dwell-${dwellLabel(opts as DwellOptions)}`
106
+ : `bucket-${bucketId}-on-${kind}`;
107
+ }
108
+
109
+ /**
110
+ * Discriminate the `(opts?, handler)` overload by argument type: a function in
111
+ * the first slot is the handler (opts undefined); an object in the first slot is
112
+ * opts and the second slot is the handler. For `dwell`, opts is mandatory and
113
+ * must carry exactly one of `after`/`every` — a `TypeError` otherwise.
114
+ */
115
+ export function normalizeOnArgs(
116
+ kind: "enter" | "leave" | "dwell",
117
+ a: unknown,
118
+ b?: unknown,
119
+ ): {
120
+ opts: EnterOptions | LeaveOptions | DwellOptions | undefined;
121
+ handler: BucketOnHandler<"enter" | "leave" | "dwell">;
122
+ } {
123
+ let opts: EnterOptions | LeaveOptions | DwellOptions | undefined;
124
+ let handler: BucketOnHandler<"enter" | "leave" | "dwell">;
125
+
126
+ if (typeof a === "function") {
127
+ opts = undefined;
128
+ handler = a as BucketOnHandler<"enter" | "leave" | "dwell">;
129
+ } else {
130
+ opts = a as EnterOptions | LeaveOptions | DwellOptions | undefined;
131
+ handler = b as BucketOnHandler<"enter" | "leave" | "dwell">;
132
+ }
133
+
134
+ if (typeof handler !== "function") {
135
+ throw new TypeError(`bucket.on("${kind}") requires a handler function`);
136
+ }
137
+
138
+ if (kind === "dwell") {
139
+ const dwell = opts as DwellOptions | undefined;
140
+ const hasAfter = dwell?.after != null;
141
+ const hasEvery = dwell?.every != null;
142
+ if (hasAfter === hasEvery) {
143
+ throw new TypeError(
144
+ 'bucket.on("dwell") requires exactly one of `after` or `every`',
145
+ );
146
+ }
147
+ }
148
+
149
+ return { opts, handler };
150
+ }
151
+
152
+ /**
153
+ * Desugar `bucket.on(kind, opts?, handler)` to a real `defineJourney` output
154
+ * tagged with `sourceBucketId` + `reactionKind`. The reaction IS a journey, so
155
+ * it inherits the entire enrollment guard stack, the active-state dedup, the
156
+ * durable context, and event routing for free.
157
+ *
158
+ * The handler ctx is built by SPREAD (`{ ...ctx, ...extras }`), never by
159
+ * mutating the engine's canonical ctx (which is shared/closed). The
160
+ * `firstEntryOnly`/`reason` filters run inside `run` AFTER enrollment.
161
+ */
162
+ export function buildBucketReaction(args: {
163
+ bucket: DefinedBucket;
164
+ kind: "enter" | "leave" | "dwell";
165
+ opts: EnterOptions | LeaveOptions | DwellOptions | undefined;
166
+ handler: BucketOnHandler<"enter" | "leave" | "dwell">;
167
+ }): DefinedJourney {
168
+ const { bucket, kind, opts, handler } = args;
169
+
170
+ const triggerEvent =
171
+ kind === "enter"
172
+ ? bucket.entered
173
+ : kind === "leave"
174
+ ? bucket.left
175
+ : `bucket:dwell:${bucket.meta.id}:${dwellLabel(opts as DwellOptions)}`;
176
+
177
+ const meta: JourneyMeta = {
178
+ id: reactionJourneyId(bucket.meta.id, kind, opts),
179
+ name: `${bucket.meta.name} — on ${kind}`,
180
+ enabled: bucket.meta.enabled,
181
+ trigger: { event: triggerEvent },
182
+ // Re-entry is a FILTER, never gated here.
183
+ entryLimit: "unlimited",
184
+ // Reactions intentionally have no re-entry cool-down.
185
+ suppress: { seconds: 0 },
186
+ sourceBucketId: bucket.meta.id,
187
+ reactionKind: kind,
188
+ ...(kind === "dwell"
189
+ ? { dwellSchedule: parseDwellSchedule(opts as DwellOptions) }
190
+ : {}),
191
+ };
192
+
193
+ return defineJourney({
194
+ meta,
195
+ run: async (user, ctx) => {
196
+ const p = user.properties;
197
+ if (kind === "enter") {
198
+ const entryCount = Number(p.entryCount ?? 1);
199
+ const isFirstEntry = entryCount === 1;
200
+ if (
201
+ (opts as EnterOptions | undefined)?.firstEntryOnly &&
202
+ !isFirstEntry
203
+ ) {
204
+ return;
205
+ }
206
+ await (handler as BucketOnHandler<"enter">)(user, {
207
+ ...ctx,
208
+ entryCount,
209
+ isFirstEntry,
210
+ });
211
+ } else if (kind === "leave") {
212
+ const reason = (p.reason as BucketLeaveReason) ?? "criteria";
213
+ const want = (opts as LeaveOptions | undefined)?.reason;
214
+ if (want && !asArray(want).includes(reason)) return;
215
+ await (handler as BucketOnHandler<"leave">)(user, { ...ctx, reason });
216
+ } else {
217
+ const dwellCount = Number(p.dwellCount ?? 1);
218
+ await (handler as BucketOnHandler<"dwell">)(user, {
219
+ ...ctx,
220
+ dwellCount,
221
+ });
222
+ }
223
+ },
224
+ });
225
+ }
@@ -379,6 +379,7 @@ async function handleLeave(opts: {
379
379
  userEmail,
380
380
  epoch: flipped.entryCount,
381
381
  source: "event",
382
+ reason: "criteria",
382
383
  });
383
384
 
384
385
  return { bucketId: bucket.id, transition: "left" };
@@ -1,6 +1,16 @@
1
1
  import { type CriteriaBuilder, criteriaBuilder } from "@hogsend/core";
2
2
  import type { BucketMeta, ConditionEval } from "@hogsend/core/types";
3
+ import type { DefinedJourney } from "../journeys/define-journey.js";
3
4
  import type { hatchet } from "../lib/hatchet.js";
5
+ import { type BucketAccessor, createBucketAccessor } from "./bucket-access.js";
6
+ import {
7
+ type BucketOnHandler,
8
+ buildBucketReaction,
9
+ type DwellOptions,
10
+ type EnterOptions,
11
+ type LeaveOptions,
12
+ normalizeOnArgs,
13
+ } from "./bucket-reactions.js";
4
14
 
5
15
  /**
6
16
  * `criteria` may be authored two ways:
@@ -15,12 +25,19 @@ export type CriteriaInput =
15
25
  | ((b: CriteriaBuilder) => ConditionEval);
16
26
 
17
27
  /** `BucketMeta` as authored — `criteria` accepts the builder function too. */
18
- export type BucketMetaInput = Omit<BucketMeta, "criteria"> & {
19
- criteria?: CriteriaInput;
20
- };
28
+ export type BucketMetaInput<Id extends string = string> = Omit<
29
+ BucketMeta,
30
+ "criteria" | "id"
31
+ > & { id: Id; criteria?: CriteriaInput };
21
32
 
22
- export interface DefinedBucket {
33
+ export type { DwellOptions };
34
+
35
+ export interface DefinedBucket<Id extends string = string> {
23
36
  meta: BucketMeta;
37
+ /** Literal-typed transition ref: `"bucket:entered:<id>"`. */
38
+ readonly entered: `bucket:entered:${Id}`;
39
+ /** Literal-typed transition ref: `"bucket:left:<id>"`. */
40
+ readonly left: `bucket:left:${Id}`;
24
41
  /**
25
42
  * The only task a bucket ever holds is the opt-in per-user fast-expiry timer,
26
43
  * which is a DURABLE task (it `ctx.sleepFor`s — Section 6.5), so the type MUST
@@ -31,11 +48,34 @@ export interface DefinedBucket {
31
48
  * `bucketReconcileTask` handles time-based leaves regardless.
32
49
  */
33
50
  task?: ReturnType<typeof hatchet.durableTask>;
51
+ /** Reaction journeys generated by `.on()`. Read by the worker + container. */
52
+ reactions: DefinedJourney[];
53
+ count: BucketAccessor["count"];
54
+ has: BucketAccessor["has"];
55
+ members: BucketAccessor["members"];
56
+ membersIterator: BucketAccessor["membersIterator"];
57
+ on(kind: "enter", handler: BucketOnHandler<"enter">): DefinedBucket<Id>;
58
+ on(
59
+ kind: "enter",
60
+ opts: EnterOptions,
61
+ handler: BucketOnHandler<"enter">,
62
+ ): DefinedBucket<Id>;
63
+ on(kind: "leave", handler: BucketOnHandler<"leave">): DefinedBucket<Id>;
64
+ on(
65
+ kind: "leave",
66
+ opts: LeaveOptions,
67
+ handler: BucketOnHandler<"leave">,
68
+ ): DefinedBucket<Id>;
69
+ on(
70
+ kind: "dwell",
71
+ opts: DwellOptions,
72
+ handler: BucketOnHandler<"dwell">,
73
+ ): DefinedBucket<Id>;
34
74
  }
35
75
 
36
- export function defineBucket(options: {
37
- meta: BucketMetaInput;
38
- }): DefinedBucket {
76
+ export function defineBucket<const Id extends string>(options: {
77
+ meta: BucketMetaInput<Id>;
78
+ }): DefinedBucket<Id> {
39
79
  // The ONLY transform defineBucket performs is resolving a builder-function
40
80
  // `criteria` to its `ConditionEval` (a one-shot, definition-time call). It does
41
81
  // NOT validate or build any task — `bucketMetaSchema.parse` still happens at
@@ -48,5 +88,36 @@ export function defineBucket(options: {
48
88
  criteria:
49
89
  typeof criteria === "function" ? criteria(criteriaBuilder) : criteria,
50
90
  };
51
- return { meta };
91
+
92
+ // Pure string derivation — synchronous, no cross-module value binding (the
93
+ // ESM-cycle resolution): entered/left are stable the instant defineBucket
94
+ // returns, computed from meta.id, NOT bound to any other module's value.
95
+ const entered = `bucket:entered:${meta.id}` as `bucket:entered:${Id}`;
96
+ const left = `bucket:left:${meta.id}` as `bucket:left:${Id}`;
97
+ const reactions: DefinedJourney[] = [];
98
+ const accessor = createBucketAccessor(meta.id);
99
+
100
+ const bucket: DefinedBucket<Id> = {
101
+ meta,
102
+ entered,
103
+ left,
104
+ reactions,
105
+ count: accessor.count,
106
+ has: accessor.has,
107
+ members: accessor.members,
108
+ membersIterator: accessor.membersIterator,
109
+ on(kind: "enter" | "leave" | "dwell", a: unknown, b?: unknown) {
110
+ const { opts, handler } = normalizeOnArgs(kind, a, b);
111
+ reactions.push(
112
+ buildBucketReaction({
113
+ bucket: bucket as DefinedBucket,
114
+ kind,
115
+ opts,
116
+ handler,
117
+ }),
118
+ );
119
+ return bucket;
120
+ },
121
+ };
122
+ return bucket;
52
123
  }
@@ -1,4 +1,5 @@
1
1
  import { BucketRegistry } from "@hogsend/core/registry";
2
+ import type { DefinedJourney } from "../journeys/define-journey.js";
2
3
  import { parseEnabledFilter } from "../journeys/registry.js";
3
4
  import { bucketExpiryTask } from "../workflows/bucket-reconcile.js";
4
5
  import type { DefinedBucket } from "./define-bucket.js";
@@ -60,3 +61,83 @@ export function selectBucketTasks(
60
61
  // the DefinedBucket task shape — both are `hatchet.durableTask` returns.
61
62
  return [bucketExpiryTask as NonNullable<DefinedBucket["task"]>];
62
63
  }
64
+
65
+ /**
66
+ * Select the Hatchet durable tasks for the reaction journeys generated by
67
+ * `bucket.on()` across the ENABLED buckets (Section 9). Reactions are
68
+ * bucket-owned, so they are gated by `ENABLED_BUCKETS` — NOT `ENABLED_JOURNEYS`
69
+ * (their `bucket-<id>-on-<kind>` ids never appear in a consumer's
70
+ * `ENABLED_JOURNEYS` csv, so folding them into the journeys[] array would drop
71
+ * every reaction whenever ENABLED_JOURNEYS is a csv).
72
+ *
73
+ * Asserts no reaction-id collision (a build-time loud failure) before returning.
74
+ */
75
+ export function selectBucketReactionTasks(
76
+ buckets: DefinedBucket[],
77
+ enabledFilter?: string,
78
+ userJourneyIds?: Iterable<string>,
79
+ ): NonNullable<DefinedBucket["task"]>[] {
80
+ const enabled = parseEnabledFilter(enabledFilter);
81
+ const enabledBuckets = buckets.filter(
82
+ (b) => enabled === "*" || enabled.has(b.meta.id),
83
+ );
84
+ // Loud boot failure on a duplicate reaction id / user-journey collision
85
+ // instead of a silent register-last-wins drop (Section 9). Pass the consumer
86
+ // journey ids so a reaction id colliding with a hand-written journey throws.
87
+ assertNoReactionIdCollisions(enabledBuckets, userJourneyIds);
88
+ return enabledBuckets.flatMap((b) =>
89
+ b.reactions.map((r) => r.task),
90
+ ) as NonNullable<DefinedBucket["task"]>[];
91
+ }
92
+
93
+ /**
94
+ * Collect the reaction `DefinedJourney`s across the ENABLED buckets (Section 9).
95
+ * Bucket-gated by `ENABLED_BUCKETS` for the same reason as
96
+ * {@link selectBucketReactionTasks}. The container registers their metas into the
97
+ * journey registry directly (bypassing the ENABLED_JOURNEYS filter) so the admin
98
+ * `feedsJourneys` and the dwell cron can resolve them.
99
+ */
100
+ export function collectBucketReactionJourneys(
101
+ buckets: DefinedBucket[],
102
+ enabledFilter?: string,
103
+ ): DefinedJourney[] {
104
+ const enabled = parseEnabledFilter(enabledFilter);
105
+ return buckets
106
+ .filter((b) => enabled === "*" || enabled.has(b.meta.id))
107
+ .flatMap((b) => b.reactions);
108
+ }
109
+
110
+ /**
111
+ * Reaction tasks register on Hatchet under `journey-${meta.id}`. Two buckets
112
+ * sharing an id, two reactions colliding on a generated id, or a user journey
113
+ * named `bucket-<id>-on-<kind>` collide silently (register-last-wins) or throw on
114
+ * boot. Throw a descriptive build-time error instead (Section 9).
115
+ *
116
+ * @param enabledBuckets the already-enabled buckets to scan.
117
+ * @param userJourneyIds ids of the consumer's hand-written journeys (optional);
118
+ * a reaction id colliding with one of these throws.
119
+ */
120
+ export function assertNoReactionIdCollisions(
121
+ enabledBuckets: DefinedBucket[],
122
+ userJourneyIds?: Iterable<string>,
123
+ ): void {
124
+ const seen = new Set<string>();
125
+ const userIds = new Set(userJourneyIds ?? []);
126
+
127
+ for (const bucket of enabledBuckets) {
128
+ for (const reaction of bucket.reactions) {
129
+ const id = reaction.meta.id;
130
+ if (seen.has(id)) {
131
+ throw new Error(
132
+ `Bucket reaction id collision: "${id}" is generated by more than one reaction (check for duplicate bucket ids or two reactions of the same kind on bucket "${bucket.meta.id}").`,
133
+ );
134
+ }
135
+ if (userIds.has(id)) {
136
+ throw new Error(
137
+ `Bucket reaction id collision: generated reaction "${id}" (bucket "${bucket.meta.id}") collides with a user journey of the same id.`,
138
+ );
139
+ }
140
+ seen.add(id);
141
+ }
142
+ }
143
+ }