@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.
@@ -7,6 +7,7 @@ import {
7
7
  durationToMs,
8
8
  evaluateCondition,
9
9
  } from "@hogsend/core";
10
+ import type { JourneyMeta } from "@hogsend/core/types";
10
11
  import {
11
12
  bucketConfigs,
12
13
  bucketMemberships,
@@ -27,6 +28,7 @@ import {
27
28
  or,
28
29
  sql,
29
30
  } from "drizzle-orm";
31
+ import type { BucketLeaveReason } from "../buckets/bucket-reactions.js";
30
32
  import { shouldEmitJoin } from "../buckets/check-membership.js";
31
33
  import {
32
34
  BUCKET_EVENT_PREFIX,
@@ -97,12 +99,21 @@ export const bucketReconcileTask = hatchet.task({
97
99
  // kind:"manual" buckets are NEVER auto-recomputed (early-continue).
98
100
  if (bucket.kind === "manual" || !bucket.criteria) continue;
99
101
 
100
- // Process a bucket here iff a clock can flip its membership: a TIME-BASED
101
- // criteria window (criteria-driven leaves/joins) OR an unconditional
102
- // `maxDwell` TTL (membership-age-driven leaves). timeBased is honoured
103
- // explicitly OR inferred from a `within` window.
102
+ // Process a bucket here iff a clock can flip its membership OR fire a
103
+ // membership-age dwell: a TIME-BASED criteria window (criteria-driven
104
+ // leaves/joins), an unconditional `maxDwell` TTL (membership-age-driven
105
+ // leaves), OR a `dwell` reaction (membership-age-driven fire). timeBased is
106
+ // honoured explicitly OR inferred from a `within` window. The dwell-only
107
+ // bucket falls through and runs ONLY the dwell pass (the criteria pass is
108
+ // behind `if (timeBased)`, the TTL pass behind `if (bucket.maxDwell)`).
104
109
  const timeBased = isTimeBased(bucket);
105
- if (!timeBased && !bucket.maxDwell) continue;
110
+ const dwellReactions = journeyRegistry
111
+ .getAll()
112
+ .filter(
113
+ (j) => j.sourceBucketId === bucket.id && j.reactionKind === "dwell",
114
+ );
115
+ const hasDwell = dwellReactions.length > 0;
116
+ if (!timeBased && !bucket.maxDwell && !hasDwell) continue;
106
117
 
107
118
  try {
108
119
  if (timeBased) {
@@ -145,6 +156,21 @@ export const bucketReconcileTask = hatchet.task({
145
156
  bucket,
146
157
  });
147
158
  }
159
+
160
+ // Dwell pass — runs AFTER the TTL pass (ordering is load-bearing: a
161
+ // member force-left by maxDwell earlier this iteration is status='left'
162
+ // here, so the dwell scan's status='active' filter excludes it). Fires
163
+ // `bucket:dwell:<id>:<label>` over the continuously-dwelling active
164
+ // population at cron resolution (Section 6.4–6.6).
165
+ if (hasDwell) {
166
+ reconciled += await reconcileBucketDwell({
167
+ db,
168
+ logger,
169
+ journeyRegistry,
170
+ bucket,
171
+ dwellReactions,
172
+ });
173
+ }
148
174
  } catch (err) {
149
175
  logger.error("Bucket reconcile failed", {
150
176
  bucketId: bucket.id,
@@ -255,6 +281,8 @@ export const bucketExpiryTask = hatchet.durableTask({
255
281
  userEmail: input.userEmail,
256
282
  epoch: flipped.entryCount,
257
283
  source: "reconcile",
284
+ // Fast-expiry is a criteria re-confirm leave (Section 6.7).
285
+ reason: "criteria",
258
286
  });
259
287
 
260
288
  return { status: "left", rowId: flipped.id };
@@ -287,6 +315,7 @@ async function reconcileBucketLeaves(opts: {
287
315
  journeyRegistry,
288
316
  bucket,
289
317
  userIds: leaverIds,
318
+ reason: "criteria",
290
319
  });
291
320
  }
292
321
 
@@ -421,7 +450,14 @@ async function reconcileCompositeLeaves(opts: {
421
450
  }
422
451
 
423
452
  if (leaverIds.length === 0) return 0;
424
- return bulkLeave({ db, logger, journeyRegistry, bucket, userIds: leaverIds });
453
+ return bulkLeave({
454
+ db,
455
+ logger,
456
+ journeyRegistry,
457
+ bucket,
458
+ userIds: leaverIds,
459
+ reason: "criteria",
460
+ });
425
461
  }
426
462
 
427
463
  /**
@@ -462,6 +498,7 @@ async function reconcileBucketTtlLeaves(opts: {
462
498
  journeyRegistry,
463
499
  bucket,
464
500
  userIds: expired.map((r) => r.userId),
501
+ reason: "maxDwell",
465
502
  });
466
503
  }
467
504
 
@@ -478,8 +515,10 @@ async function bulkLeave(opts: {
478
515
  journeyRegistry: ReturnType<typeof getJourneyRegistrySingleton>;
479
516
  bucket: BucketMeta;
480
517
  userIds: string[];
518
+ /** Why these members leave — TTL passes "maxDwell", criteria passes "criteria". */
519
+ reason: BucketLeaveReason;
481
520
  }): Promise<number> {
482
- const { db, logger, journeyRegistry, bucket, userIds } = opts;
521
+ const { db, logger, journeyRegistry, bucket, userIds, reason } = opts;
483
522
 
484
523
  const dwellMs = bucket.minDwell ? durationToMs(bucket.minDwell) : 0;
485
524
  const dwellCutoff = dwellMs > 0 ? new Date(Date.now() - dwellMs) : null;
@@ -524,12 +563,171 @@ async function bulkLeave(opts: {
524
563
  userEmail: row.userEmail,
525
564
  epoch: row.entryCount,
526
565
  source: "reconcile",
566
+ reason,
527
567
  });
528
568
  }
529
569
 
530
570
  return flipped.length;
531
571
  }
532
572
 
573
+ /**
574
+ * Dwell pass for one bucket (Section 6.4–6.6). Fires `bucket:dwell:<id>:<label>`
575
+ * over the EXISTING continuously-dwelling active population at cron resolution —
576
+ * its unique value over `on("enter") + ctx.sleep`. Idempotent across sweeps,
577
+ * interoperable with maxDwell/fastExpiry, and routed through
578
+ * `emitBucketTransition` (NOT a raw push) for `userEvents`/exitOn/history/analytics
579
+ * parity (Section 6.1):
580
+ *
581
+ * - PUSH FIRST (at-least-once; the deterministic idempotencyKey + the userEvents
582
+ * dedup absorb a same-sweep retry), THEN stamp `dwellState` (the inter-sweep
583
+ * "already fired this membership" gate). The stamp's `status='active'` clause
584
+ * makes the leave/fastExpiry interop correct (a row flipped to `left` between
585
+ * SELECT and UPDATE no-ops).
586
+ * - The dwell clock is `coalesce(dwellAnchorAt, enteredAt)` — backfilled members
587
+ * use their derived historical anchor (LOCKED DECISION 1), live joins use
588
+ * enteredAt (anchor NULL).
589
+ * - Candidates are ordered `lastEvaluatedAt asc nulls first` (oldest-served-first)
590
+ * so a busy `every` bucket cannot starve members past BATCH_SIZE; the stamp
591
+ * bumps `lastEvaluatedAt`, advancing the cursor. Hitting BATCH_SIZE is logged
592
+ * once per sweep (visibility, not silent).
593
+ * - First-deploy quiet window: reuse `firstTimeBackfillIncomplete` so the
594
+ * pre-existing/backfilled population is not blasted before the first-time
595
+ * backfill has settled.
596
+ *
597
+ * `every` is fires-at-most-once-per-sweep, coalescing (one catch-up fire after a
598
+ * multi-interval outage); `dwellCount` is the deterministic interval ordinal
599
+ * `floor((sweepInstant - anchor) / offsetMs)` (gap-stable, NOT a fire count). For
600
+ * `after` the ordinal is always 1 (one-shot).
601
+ */
602
+ async function reconcileBucketDwell(opts: {
603
+ db: Database;
604
+ logger: Logger;
605
+ journeyRegistry: ReturnType<typeof getJourneyRegistrySingleton>;
606
+ bucket: BucketMeta;
607
+ dwellReactions: JourneyMeta[];
608
+ }): Promise<number> {
609
+ const { db, logger, journeyRegistry, bucket, dwellReactions } = opts;
610
+
611
+ // First-deploy quiet window: do not blast the pre-existing/backfilled
612
+ // population before the first-time backfill has settled (reuse the guard).
613
+ if (await firstTimeBackfillIncomplete(db, bucket)) return 0;
614
+
615
+ // Captured once per invocation and reused for the ordinal. The ordinal is
616
+ // floor((sweepInstant - anchor) / offsetMs), so it is grid-quantized: a Hatchet
617
+ // retry (a fresh fn() invocation, seconds–minutes later) lands in the SAME
618
+ // interval window and recomputes the SAME ordinal → SAME idempotencyKey →
619
+ // absorbed by the userEvents dedup. `after` is always ordinal 1 (fully stable).
620
+ // Residual edge: an `every` retry that straddles an interval boundary yields a
621
+ // new ordinal and thus one extra dwell fire — bounded to a single duplicate by
622
+ // the key, and only possible for sub-retry-window intervals. Documented, not
623
+ // load-bearing for the common (hours/days) intervals.
624
+ const sweepInstant = Date.now();
625
+ let fired = 0;
626
+
627
+ for (const reaction of dwellReactions) {
628
+ const schedule = reaction.dwellSchedule;
629
+ if (!schedule) continue;
630
+ const { label, after, every } = schedule;
631
+ const offsetMs = after ?? every;
632
+ if (offsetMs == null) continue;
633
+ const cutoff = new Date(sweepInstant - offsetMs);
634
+
635
+ // Continuous-member gate. coalesce(dwellAnchorAt, enteredAt) is the dwell
636
+ // clock. Oldest-served-first (Section 6.5).
637
+ const candidates = await db
638
+ .select({
639
+ id: bucketMemberships.id,
640
+ userId: bucketMemberships.userId,
641
+ userEmail: bucketMemberships.userEmail,
642
+ entryCount: bucketMemberships.entryCount,
643
+ anchor: sql<Date>`coalesce(${bucketMemberships.dwellAnchorAt}, ${bucketMemberships.enteredAt})`,
644
+ dwellState: bucketMemberships.dwellState,
645
+ })
646
+ .from(bucketMemberships)
647
+ .innerJoin(contacts, eq(contacts.externalId, bucketMemberships.userId))
648
+ .where(
649
+ and(
650
+ eq(bucketMemberships.bucketId, bucket.id),
651
+ eq(bucketMemberships.status, "active"),
652
+ isNull(bucketMemberships.deletedAt),
653
+ isNull(contacts.deletedAt),
654
+ // Fold the comparison into the fragment with an explicit cast: a JS
655
+ // Date passed to lte() against a raw sql`coalesce(...)` fragment has no
656
+ // column type to drive param encoding, so the pg driver throws on the
657
+ // Date (and the per-bucket try/catch would silently swallow it → 0
658
+ // dwell fires). Binding the ISO string + ::timestamptz is well-typed.
659
+ sql`coalesce(${bucketMemberships.dwellAnchorAt}, ${bucketMemberships.enteredAt}) <= ${cutoff.toISOString()}::timestamptz`,
660
+ ),
661
+ )
662
+ .orderBy(sql`${bucketMemberships.lastEvaluatedAt} asc nulls first`)
663
+ .limit(BATCH_SIZE);
664
+
665
+ if (candidates.length >= BATCH_SIZE) {
666
+ logger.warn("Bucket dwell pass bounded to BATCH_SIZE/tick", {
667
+ bucketId: bucket.id,
668
+ label,
669
+ batchSize: BATCH_SIZE,
670
+ });
671
+ }
672
+
673
+ for (const m of candidates) {
674
+ const state = (m.dwellState ?? {}) as Record<string, string>;
675
+ const lastFired = state[label] ? Date.parse(state[label]) : null;
676
+ const anchorMs = new Date(m.anchor).getTime();
677
+
678
+ if (after != null) {
679
+ // one-shot: already fired for this membership → skip.
680
+ if (lastFired != null) continue;
681
+ } else {
682
+ // every: not yet due since the last fire (or the anchor).
683
+ const since = lastFired ?? anchorMs;
684
+ if (sweepInstant - since < offsetMs) continue;
685
+ }
686
+
687
+ // Deterministic per (membership, sweepInstant) so a retry recomputes it.
688
+ const ordinal =
689
+ after != null ? 1 : Math.floor((sweepInstant - anchorMs) / offsetMs);
690
+
691
+ // PUSH FIRST (at-least-once; idempotencyKey + userEvents dedup absorb
692
+ // retries), THEN stamp. emitBucketTransition handles the
693
+ // userEvents/exitOn/analytics parity.
694
+ await emitBucketTransition({
695
+ db,
696
+ registry: journeyRegistry,
697
+ hatchet,
698
+ logger,
699
+ kind: "dwell",
700
+ bucket,
701
+ userId: m.userId,
702
+ userEmail: m.userEmail,
703
+ epoch: m.entryCount,
704
+ source: "reconcile",
705
+ dwellLabel: label,
706
+ dwellOrdinal: ordinal,
707
+ });
708
+
709
+ // Stamp the membership (inter-sweep gate). status='active' clause = leave
710
+ // interop (a row flipped to 'left' between SELECT and UPDATE no-ops).
711
+ await db
712
+ .update(bucketMemberships)
713
+ .set({
714
+ dwellState: sql`jsonb_set(coalesce(${bucketMemberships.dwellState}, '{}'::jsonb), ${`{${label}}`}, ${`"${new Date(sweepInstant).toISOString()}"`}::jsonb)`,
715
+ lastEvaluatedAt: new Date(),
716
+ updatedAt: new Date(),
717
+ })
718
+ .where(
719
+ and(
720
+ eq(bucketMemberships.id, m.id),
721
+ eq(bucketMemberships.status, "active"),
722
+ ),
723
+ );
724
+ fired += 1;
725
+ }
726
+ }
727
+
728
+ return fired;
729
+ }
730
+
533
731
  /**
534
732
  * reconcileJoins (absence buckets): materialize NEW members the real-time path
535
733
  * cannot see — a user who STOPS doing X fires no event, so only the clock can