@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/src/container.ts CHANGED
@@ -14,8 +14,12 @@ import {
14
14
  createResendProvider,
15
15
  } from "@hogsend/plugin-resend";
16
16
  import type { Resend } from "resend";
17
+ import { createBucketAccessor } from "./buckets/bucket-access.js";
17
18
  import type { DefinedBucket } from "./buckets/define-bucket.js";
18
- import { buildBucketRegistry } from "./buckets/registry.js";
19
+ import {
20
+ buildBucketRegistry,
21
+ collectBucketReactionJourneys,
22
+ } from "./buckets/registry.js";
19
23
  import { env } from "./env.js";
20
24
  import { setClientScheduleDefaults } from "./journeys/client-defaults-singleton.js";
21
25
  import type { DefinedJourney } from "./journeys/define-journey.js";
@@ -200,10 +204,38 @@ export function createHogsendClient(
200
204
  // Installs the bucket registry singleton in BOTH the API and worker processes
201
205
  // (both call createHogsendClient); the real-time ingest path reads it via
202
206
  // getBucketRegistrySingleton().
203
- const bucketRegistry = buildBucketRegistry(
204
- opts.buckets ?? [],
205
- opts.enabledBuckets ?? env.ENABLED_BUCKETS,
206
- );
207
+ const buckets = opts.buckets ?? [];
208
+ const enabledBuckets = opts.enabledBuckets ?? env.ENABLED_BUCKETS;
209
+ const bucketRegistry = buildBucketRegistry(buckets, enabledBuckets);
210
+
211
+ // Register the reaction journeys generated by `bucket.on()` into the journey
212
+ // registry AFTER buildJourneyRegistry, bypassing the ENABLED_JOURNEYS filter:
213
+ // reactions are bucket-owned and were already gated by ENABLED_BUCKETS
214
+ // (collectBucketReactionJourneys), so their `bucket-<id>-on-<kind>` ids must NOT
215
+ // be subject to the journeys csv (Section 9). Both API and worker call
216
+ // createHogsendClient, so the singleton carries reaction metas in both
217
+ // processes (needed for admin feedsJourneys + the dwell-cron lookup).
218
+ for (const reaction of collectBucketReactionJourneys(
219
+ buckets,
220
+ enabledBuckets,
221
+ )) {
222
+ registry.register(reaction.meta);
223
+ }
224
+
225
+ // Re-bind the member-access accessors on each enabled bucket to THIS
226
+ // container's db so `overrides.db` flows through (the accessors default to the
227
+ // getDb() singleton at defineBucket time, before any container exists —
228
+ // bucket-access.ts dbResolver seam). The enabled set mirrors
229
+ // buildBucketRegistry's filter.
230
+ const enabledIds = new Set(bucketRegistry.getAll().map((b) => b.id));
231
+ for (const bucket of buckets) {
232
+ if (!enabledIds.has(bucket.meta.id)) continue;
233
+ const accessor = createBucketAccessor(bucket.meta.id, () => db);
234
+ bucket.count = accessor.count;
235
+ bucket.has = accessor.has;
236
+ bucket.members = accessor.members;
237
+ bucket.membersIterator = accessor.membersIterator;
238
+ }
207
239
 
208
240
  const provider =
209
241
  opts.email?.provider ??
package/src/index.ts CHANGED
@@ -39,6 +39,18 @@ export {
39
39
  // --- App / container / worker factories ---
40
40
  export { type AppEnv, type CreateAppOptions, createApp } from "./app.js";
41
41
  // --- Buckets ---
42
+ export {
43
+ type BucketAccessor,
44
+ type BucketMemberRow,
45
+ createBucketAccessor,
46
+ type MembersResult,
47
+ } from "./buckets/bucket-access.js";
48
+ export type {
49
+ BucketLeaveReason,
50
+ DwellOptions,
51
+ EnterOptions,
52
+ LeaveOptions,
53
+ } from "./buckets/bucket-reactions.js";
42
54
  export {
43
55
  type BucketTransition,
44
56
  type BucketTransitionKind,
@@ -50,6 +62,8 @@ export {
50
62
  } from "./buckets/define-bucket.js";
51
63
  export {
52
64
  buildBucketRegistry,
65
+ collectBucketReactionJourneys,
66
+ selectBucketReactionTasks,
53
67
  selectBucketTasks,
54
68
  } from "./buckets/registry.js";
55
69
  export {
package/src/lib/boot.ts CHANGED
@@ -125,12 +125,20 @@ export interface WorkerReadyInfo {
125
125
  client: HogsendClient;
126
126
  journeyTasks: number;
127
127
  bucketTasks: number;
128
+ /** Reaction journey tasks generated by `bucket.on()` (Section 9). */
129
+ bucketReactionTasks: number;
128
130
  builtinTasks: number;
129
131
  }
130
132
 
131
133
  /** Render the worker "ready" output (banner in dev TTY, structured log otherwise). */
132
134
  export function reportWorkerReady(info: WorkerReadyInfo): void {
133
- const { client, journeyTasks, bucketTasks, builtinTasks } = info;
135
+ const {
136
+ client,
137
+ journeyTasks,
138
+ bucketTasks,
139
+ bucketReactionTasks,
140
+ builtinTasks,
141
+ } = info;
134
142
  const engineVersion = getEngineVersion();
135
143
  const hatchetHost = client.env.HATCHET_CLIENT_HOST_PORT;
136
144
 
@@ -141,6 +149,7 @@ export function reportWorkerReady(info: WorkerReadyInfo): void {
141
149
  namespace: client.env.HATCHET_CLIENT_NAMESPACE || undefined,
142
150
  journeyTasks,
143
151
  bucketTasks,
152
+ bucketReactionTasks,
144
153
  builtinTasks,
145
154
  });
146
155
  return;
@@ -151,6 +160,7 @@ export function reportWorkerReady(info: WorkerReadyInfo): void {
151
160
  const tasks = [
152
161
  plural(journeyTasks, "journey task"),
153
162
  plural(bucketTasks, "bucket task"),
163
+ plural(bucketReactionTasks, "reaction task"),
154
164
  plural(builtinTasks, "built-in task"),
155
165
  ].join(dim(" · "));
156
166
 
@@ -2,11 +2,12 @@ import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
2
2
  import type { BucketMeta } from "@hogsend/core";
3
3
  import type { JourneyRegistry } from "@hogsend/core/registry";
4
4
  import type { Database } from "@hogsend/db";
5
+ import type { BucketLeaveReason } from "../buckets/bucket-reactions.js";
5
6
  import { syncBucketToPostHog } from "./bucket-posthog-sync.js";
6
7
  import { ingestEvent } from "./ingestion.js";
7
8
  import type { Logger } from "./logger.js";
8
9
 
9
- export type BucketTransitionKind = "entered" | "left";
10
+ export type BucketTransitionKind = "entered" | "left" | "dwell";
10
11
 
11
12
  /** Where a transition originated — carried on the emitted event properties. */
12
13
  export type BucketTransitionSource =
@@ -40,6 +41,16 @@ export async function emitBucketTransition(opts: {
40
41
  userEmail: string | null;
41
42
  epoch: number;
42
43
  source?: BucketTransitionSource;
44
+ /** Carried on a `left` transition's properties → `ctx.reason`. */
45
+ reason?: BucketLeaveReason;
46
+ /** The dwell schedule label (`after-<ms>`/`every-<ms>`) — `dwell` only. */
47
+ dwellLabel?: string;
48
+ /**
49
+ * The deterministic dwell interval ordinal — `dwell` only. Rides the
50
+ * idempotencyKey so a same-sweep retry recomputes the identical key and is
51
+ * absorbed by the `userEvents` dedup. Surfaced as `dwellCount`.
52
+ */
53
+ dwellOrdinal?: number;
43
54
  }): Promise<void> {
44
55
  const {
45
56
  db,
@@ -52,22 +63,53 @@ export async function emitBucketTransition(opts: {
52
63
  userEmail,
53
64
  epoch,
54
65
  source = "event",
66
+ reason,
67
+ dwellLabel,
68
+ dwellOrdinal,
55
69
  } = opts;
56
70
 
71
+ // The dwell transition emits a labelled event so two dwell reactions on one
72
+ // bucket (one `after`, one `every`) route distinctly; enter/left keep the
73
+ // canonical `bucket:<kind>:<id>` form. The idempotencyKey is recomputed
74
+ // identically by a retry: enter/left key on the membership epoch, dwell keys
75
+ // on the (label, ordinal) so a same-sweep retry rides the userEvents dedup.
76
+ const eventName =
77
+ kind === "dwell"
78
+ ? `bucket:dwell:${bucket.id}:${dwellLabel}`
79
+ : `bucket:${kind}:${bucket.id}`;
80
+ const idempotencyKey =
81
+ kind === "dwell"
82
+ ? `bucket:${bucket.id}:${userId}:dwell:${dwellLabel}:${dwellOrdinal}`
83
+ : `bucket:${bucket.id}:${userId}:${kind}:${epoch}`;
84
+
57
85
  const properties: Record<string, unknown> = {
58
86
  bucketId: bucket.id,
59
87
  bucketName: bucket.name,
60
88
  userId,
61
89
  transition: kind,
62
90
  source,
91
+ // entryCount is always carried (the membership ordinal); the reaction `run`
92
+ // derives `isFirstEntry` from it.
93
+ entryCount: epoch,
63
94
  };
95
+ // reason is carried on a leave so the `leave` reaction can filter on it.
96
+ if (kind === "left" && reason != null) {
97
+ properties.reason = reason;
98
+ }
99
+ // dwellCount = the interval ordinal, surfaced to the dwell reaction.
100
+ if (kind === "dwell" && dwellOrdinal != null) {
101
+ properties.dwellCount = dwellOrdinal;
102
+ }
64
103
 
65
104
  // Optional PostHog person-property mirror (Section 12). Off by default; a
66
105
  // no-op without POSTHOG_API_KEY. Wired here, the single transition path shared
67
106
  // by all three producers (real-time / reconcile / fast-expiry), so the sync
68
107
  // fires exactly once per emitted transition. Best-effort — it never blocks the
69
- // event emit below.
70
- syncBucketToPostHog({ logger, kind, bucket, userId });
108
+ // event emit below. Dwell is a recurring membership-age tick, not a state
109
+ // change, so it does NOT mirror a person property.
110
+ if (kind === "entered" || kind === "left") {
111
+ syncBucketToPostHog({ logger, kind, bucket, userId });
112
+ }
71
113
 
72
114
  // Per-bucket alias — the recommended, narrowly-routed binding. The
73
115
  // deterministic idempotencyKey rides the userEvents dedup short-circuit as
@@ -78,11 +120,11 @@ export async function emitBucketTransition(opts: {
78
120
  hatchet,
79
121
  logger,
80
122
  event: {
81
- event: `bucket:${kind}:${bucket.id}`,
123
+ event: eventName,
82
124
  userId,
83
125
  userEmail: userEmail ?? "",
84
126
  properties,
85
- idempotencyKey: `bucket:${bucket.id}:${userId}:${kind}:${epoch}`,
127
+ idempotencyKey,
86
128
  },
87
129
  });
88
130
 
@@ -123,6 +123,8 @@ const getRoute = createRoute({
123
123
  id: z.string(),
124
124
  name: z.string(),
125
125
  trigger: z.string(),
126
+ sourceBucketId: z.string().nullable(),
127
+ owned: z.boolean(),
126
128
  }),
127
129
  ),
128
130
  recentMembers: z.array(memberSchema),
@@ -332,27 +334,55 @@ export const bucketsRouter = new OpenAPIHono<AppEnv>()
332
334
  counts[row.status as keyof typeof emptyCounts] = row.count;
333
335
  }
334
336
 
335
- // Which journeys this bucket feeds cross-reference the bucket's emitted
336
- // transition events against the journey registry's trigger index. A journey
337
- // bound to the per-bucket alias `bucket:entered:<id>` (the recommended,
338
- // narrowly-routed binding) or the generic `bucket:entered` is woken by this
339
- // bucket's joins.
337
+ // Which journeys this bucket feeds. Two sources, owned-first:
338
+ // 1. Owned reactions journeys generated by `bucket.on()`, tagged with
339
+ // `sourceBucketId === id`. Discovered by scanning the registry; surfaced
340
+ // with `owned: true`.
341
+ // 2. External bindings — hand-written journeys bound to the bucket's emitted
342
+ // transition events via the per-bucket alias `bucket:entered:<id>` (the
343
+ // recommended, narrowly-routed binding) or the generic `bucket:entered`.
344
+ // Surfaced with `owned: false`. Owned wins on collision.
345
+ const feedsMap = new Map<
346
+ string,
347
+ {
348
+ id: string;
349
+ name: string;
350
+ trigger: string;
351
+ sourceBucketId: string | null;
352
+ owned: boolean;
353
+ }
354
+ >();
355
+
356
+ // Owned reactions: scan the journey registry for sourceBucketId === id.
357
+ for (const journey of registry
358
+ .getAll()
359
+ .filter((j) => j.sourceBucketId === id)) {
360
+ feedsMap.set(journey.id, {
361
+ id: journey.id,
362
+ name: journey.name,
363
+ trigger: journey.trigger.event,
364
+ sourceBucketId: id,
365
+ owned: true,
366
+ });
367
+ }
368
+
369
+ // External bindings: the existing alias + generic cross-reference. Skip any
370
+ // journey already surfaced as owned (owned wins).
340
371
  const feedEvents = [
341
372
  `bucket:entered:${id}`,
342
373
  `bucket:left:${id}`,
343
374
  "bucket:entered",
344
375
  "bucket:left",
345
376
  ];
346
- const feedsMap = new Map<
347
- string,
348
- { id: string; name: string; trigger: string }
349
- >();
350
377
  for (const evt of feedEvents) {
351
378
  for (const journey of registry.getByTriggerEvent(evt)) {
379
+ if (feedsMap.has(journey.id)) continue;
352
380
  feedsMap.set(journey.id, {
353
381
  id: journey.id,
354
382
  name: journey.name,
355
383
  trigger: evt,
384
+ sourceBucketId: journey.sourceBucketId ?? null,
385
+ owned: false,
356
386
  });
357
387
  }
358
388
  }
package/src/worker.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import type { DefinedBucket } from "./buckets/define-bucket.js";
2
- import { selectBucketTasks } from "./buckets/registry.js";
2
+ import {
3
+ selectBucketReactionTasks,
4
+ selectBucketTasks,
5
+ } from "./buckets/registry.js";
3
6
  import type { HogsendClient } from "./container.js";
4
7
  import type { DefinedJourney } from "./journeys/define-journey.js";
5
8
  import { selectJourneyTasks } from "./journeys/registry.js";
@@ -46,6 +49,15 @@ export function createWorker(opts: CreateWorkerOptions): Worker {
46
49
  // reconcile cron (bucketReconcileTask) is ALWAYS registered in baseWorkflows
47
50
  // below (Section 10), regardless of fastExpiry.
48
51
  const bucketTasks = selectBucketTasks(opts.buckets ?? [], enabledBuckets);
52
+ // Reaction journeys generated by `bucket.on()` desugar to real durable tasks.
53
+ // They are bucket-owned, so they are gated by ENABLED_BUCKETS (NOT
54
+ // ENABLED_JOURNEYS) and wired directly here rather than via the journeys[]
55
+ // array (Section 9). Throws loudly on a reaction-id collision.
56
+ const bucketReactionTasks = selectBucketReactionTasks(
57
+ opts.buckets ?? [],
58
+ enabledBuckets,
59
+ journeys.map((j) => j.meta.id),
60
+ );
49
61
 
50
62
  const baseWorkflows = [
51
63
  sendEmailTask,
@@ -55,6 +67,7 @@ export function createWorker(opts: CreateWorkerOptions): Worker {
55
67
  bucketBackfillTask,
56
68
  ...journeyTasks,
57
69
  ...bucketTasks,
70
+ ...bucketReactionTasks,
58
71
  ];
59
72
  const workflows = [
60
73
  ...baseWorkflows,
@@ -94,8 +107,12 @@ export function createWorker(opts: CreateWorkerOptions): Worker {
94
107
  client: container,
95
108
  journeyTasks: journeyTasks.length,
96
109
  bucketTasks: bucketTasks.length,
110
+ bucketReactionTasks: bucketReactionTasks.length,
97
111
  builtinTasks:
98
- baseWorkflows.length - journeyTasks.length - bucketTasks.length,
112
+ baseWorkflows.length -
113
+ journeyTasks.length -
114
+ bucketTasks.length -
115
+ bucketReactionTasks.length,
99
116
  });
100
117
 
101
118
  // Publish liveness so the API + Studio can show "worker connected"
@@ -15,7 +15,7 @@ import {
15
15
  importJobs,
16
16
  userEvents,
17
17
  } from "@hogsend/db";
18
- import { and, eq, gt, gte, inArray, isNull, sql } from "drizzle-orm";
18
+ import { and, eq, gt, gte, inArray, isNull, max, sql } from "drizzle-orm";
19
19
  import {
20
20
  computeExpiresAt,
21
21
  computeMaxDwellAt,
@@ -209,6 +209,18 @@ async function backfillJoins(opts: {
209
209
  // unset value would never be force-left.
210
210
  const maxDwellAt = computeMaxDwellAt(bucket);
211
211
 
212
+ // Historical dwell anchor (Section 6.3 / LOCKED DECISION 1). For a
213
+ // windowed/event criterion the anchor is `max(occurredAt)` of the qualifying
214
+ // event = "when they became dormant" (e.g. went-dormant = the last
215
+ // `app_opened`). The dwell gate reads `coalesce(dwellAnchorAt, enteredAt)`, so
216
+ // backfilled members start the dwell clock at their real historical instant
217
+ // rather than the deploy-time `enteredAt`. Shapes with no cheap per-matcher
218
+ // timestamp leave the anchor NULL (fall back to enteredAt). The live join path
219
+ // (handleJoin) never sets dwellAnchorAt, so post-deploy joins clock from their
220
+ // real enteredAt. Computed batched per chunk (one GROUP BY max(occurredAt),
221
+ // mirroring the priorCounts GROUP BY) — never per-user serial queries.
222
+ const anchorEvent = resolveDwellAnchorEvent(criteria);
223
+
212
224
  // Fix C (DEFERRED): backfilled fastExpiry rows are NOT armed with a
213
225
  // bucket:arm-expiry durable timer here — they are picked up by the next cron
214
226
  // sweep instead (reconcileBucketLeaves / reconcileBucketTtlLeaves are the
@@ -253,6 +265,35 @@ async function backfillJoins(opts: {
253
265
  priorCounts.map((r) => [r.userId, Number(r.cnt)]),
254
266
  );
255
267
 
268
+ // Batched dwell-anchor derivation (LOCKED DECISION 1): one GROUP BY
269
+ // max(occurredAt) over the qualifying event for THIS chunk, mirroring the
270
+ // priorCounts GROUP BY above (never per-user serial queries). Only computed
271
+ // when the criteria shape exposes a cheap per-matcher anchor event; an empty
272
+ // map leaves dwellAnchorAt NULL → the dwell gate falls back to enteredAt.
273
+ let anchorByUser = new Map<string, Date>();
274
+ if (anchorEvent != null) {
275
+ const anchors = await db
276
+ .select({
277
+ userId: userEvents.userId,
278
+ lastAt: max(userEvents.occurredAt),
279
+ })
280
+ .from(userEvents)
281
+ .where(
282
+ and(
283
+ eq(userEvents.event, anchorEvent),
284
+ inArray(userEvents.userId, chunk),
285
+ ),
286
+ )
287
+ .groupBy(userEvents.userId);
288
+ anchorByUser = new Map(
289
+ anchors
290
+ .filter(
291
+ (r): r is { userId: string; lastAt: Date } => r.lastAt != null,
292
+ )
293
+ .map((r) => [r.userId, r.lastAt]),
294
+ );
295
+ }
296
+
256
297
  const rows = chunk.map((userId) => ({
257
298
  userId,
258
299
  userEmail: emailByUser.get(userId) ?? null,
@@ -262,6 +303,8 @@ async function backfillJoins(opts: {
262
303
  entryCount: 1 + (priorByUser.get(userId) ?? 0),
263
304
  expiresAt: computeExpiresAt(bucket),
264
305
  maxDwellAt,
306
+ // Historical dwell anchor where derivable; NULL otherwise (→ enteredAt).
307
+ dwellAnchorAt: anchorByUser.get(userId) ?? null,
265
308
  lastEvaluatedAt: new Date(),
266
309
  }));
267
310
 
@@ -365,6 +408,7 @@ async function reevalLeaves(opts: {
365
408
  userEmail: row.userEmail,
366
409
  epoch: row.entryCount,
367
410
  source: "backfill",
411
+ reason: "criteria",
368
412
  });
369
413
  }
370
414
  leftCount += flipped.length;
@@ -504,6 +548,51 @@ async function selectCompositeMatchers(
504
548
  return matchers;
505
549
  }
506
550
 
551
+ /**
552
+ * Resolve the event whose `max(occurredAt)` is the historical dwell anchor for a
553
+ * backfilled member (LOCKED DECISION 1 / Section 6.3) — "when they became
554
+ * dormant". Returns an event name only for the windowed/event shapes that expose
555
+ * a cheap per-matcher timestamp; `null` for everything else (the anchor stays
556
+ * NULL and the dwell gate falls back to `enteredAt`):
557
+ *
558
+ * - a single windowed `event` criterion → its `eventName` (the last qualifying
559
+ * occurrence is the window boundary, e.g. the last `app_opened`).
560
+ * - the lapsed-active composite `all(event(X).exists(),
561
+ * event(X).within(W).not_exists())` → event X (the flagship went-dormant
562
+ * shape; the last X is when they lapsed).
563
+ *
564
+ * Other shapes (property/count composites, OR-of-absence, multi-event) have no
565
+ * single cheap per-matcher timestamp, so they keep a NULL anchor.
566
+ */
567
+ function resolveDwellAnchorEvent(criteria: ConditionEval): string | null {
568
+ if (criteria.type === "event") {
569
+ return criteria.within != null ? criteria.eventName : null;
570
+ }
571
+ // Lapsed-active composite — two legs on the SAME event X: an unwindowed
572
+ // exists() anchor and a windowed not_exists() leg. Mirrors
573
+ // isLapsedActiveComposite in bucket-reconcile.ts.
574
+ if (
575
+ criteria.type === "composite" &&
576
+ criteria.operator === "and" &&
577
+ criteria.conditions.length === 2
578
+ ) {
579
+ const existsLeg = criteria.conditions.find(
580
+ (c) => c.type === "event" && c.check === "exists" && c.within == null,
581
+ );
582
+ const notExistsLeg = criteria.conditions.find(
583
+ (c) => c.type === "event" && c.check === "not_exists" && c.within != null,
584
+ );
585
+ if (
586
+ existsLeg?.type === "event" &&
587
+ notExistsLeg?.type === "event" &&
588
+ existsLeg.eventName === notExistsLeg.eventName
589
+ ) {
590
+ return notExistsLeg.eventName;
591
+ }
592
+ }
593
+ return null;
594
+ }
595
+
507
596
  /**
508
597
  * Upsert the bucket's current criteria fingerprint onto `bucket_configs` (Section
509
598
  * 6.6 B). Mirrors the admin enable/disable onConflictDoUpdate target.