@hogsend/engine 0.2.0 → 0.4.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.2.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,11 +35,11 @@
35
35
  "resend": "^6.12.3",
36
36
  "winston": "^3.19.0",
37
37
  "zod": "^4.4.3",
38
- "@hogsend/core": "^0.2.0",
39
- "@hogsend/db": "^0.2.0",
40
- "@hogsend/email": "^0.1.0",
41
- "@hogsend/plugin-posthog": "^0.1.0",
42
- "@hogsend/plugin-resend": "^0.1.0"
38
+ "@hogsend/core": "^0.4.0",
39
+ "@hogsend/db": "^0.4.0",
40
+ "@hogsend/email": "^0.4.0",
41
+ "@hogsend/plugin-posthog": "^0.4.0",
42
+ "@hogsend/plugin-resend": "^0.4.0"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/node": "^22.15.3",
@@ -7,14 +7,17 @@ import {
7
7
  } from "@hogsend/core";
8
8
  import type { JourneyRegistry } from "@hogsend/core/registry";
9
9
  import { bucketMemberships, contacts, type Database } from "@hogsend/db";
10
- import { and, eq, isNull, sql } from "drizzle-orm";
10
+ import { and, desc, eq, isNotNull, isNull } from "drizzle-orm";
11
11
  import { emitBucketTransition } from "../lib/bucket-emit.js";
12
12
  import type { Logger } from "../lib/logger.js";
13
+ import {
14
+ BUCKET_EVENT_PREFIX,
15
+ computeExpiresAt,
16
+ computeMaxDwellAt,
17
+ countPriorMemberships,
18
+ } from "./membership-epoch.js";
13
19
  import { getBucketRegistrySingleton } from "./registry-singleton.js";
14
20
 
15
- /** The reserved prefix every bucket transition event carries. */
16
- const BUCKET_EVENT_PREFIX = "bucket:";
17
-
18
21
  export type BucketTransitionKind = "entered" | "left";
19
22
 
20
23
  export interface BucketTransition {
@@ -33,7 +36,7 @@ export interface BucketTransition {
33
36
  * RETURNING-gated mutation (partial-unique INSERT for joins, compare-and-swap
34
37
  * UPDATE for leaves — Section 6.3). On a real transition it emits
35
38
  * `bucket:entered:<id>` / `bucket:left:<id>` back through `ingestEvent`, gated on
36
- * the reentry policy and deferring leaves still inside `minDwell`.
39
+ * the entryLimit policy and deferring leaves still inside `minDwell`.
37
40
  *
38
41
  * Returns the computed transition list so a unit test can assert enter/leave/no-op
39
42
  * WITHOUT a live Hatchet (Section 14 — the testing seam). Production callers
@@ -221,18 +224,10 @@ async function handleJoin(opts: {
221
224
  const { db, registry, hatchet, logger, bucket, userId, userEmail } = opts;
222
225
 
223
226
  // entryCount ordinal = 1 + count of ALL prior memberships (active + left) for
224
- // this (user, bucket) (Section 6.3 / 8.2). priorCount also drives the reentry
225
- // gate.
226
- const [counted] = await db
227
- .select({ priorCount: sql<number>`count(*)::int` })
228
- .from(bucketMemberships)
229
- .where(
230
- and(
231
- eq(bucketMemberships.userId, userId),
232
- eq(bucketMemberships.bucketId, bucket.id),
233
- ),
234
- );
235
- const priorCount = Number(counted?.priorCount ?? 0);
227
+ // this (user, bucket) (Section 6.3 / 8.2). priorCount also drives the entryLimit
228
+ // gate. Shared with the reconcile-discovered join path so the ordinal can
229
+ // never drift between the two writers.
230
+ const priorCount = await countPriorMemberships(db, bucket.id, userId);
236
231
  const epoch = priorCount + 1;
237
232
 
238
233
  // INSERT a FRESH active row. ON CONFLICT DO NOTHING targets the partial active
@@ -241,9 +236,7 @@ async function handleJoin(opts: {
241
236
  // (the loser mutates nothing — Section 6.3 governing rule).
242
237
  const expiresAt = computeExpiresAt(bucket);
243
238
  // Unconditional TTL deadline — set once on join, swept by the reconcile cron.
244
- const maxDwellAt = bucket.maxDwell
245
- ? new Date(Date.now() + durationToMs(bucket.maxDwell))
246
- : null;
239
+ const maxDwellAt = computeMaxDwellAt(bucket);
247
240
  const inserted = await db
248
241
  .insert(bucketMemberships)
249
242
  .values({
@@ -284,8 +277,8 @@ async function handleJoin(opts: {
284
277
 
285
278
  // The active row is always written (Studio size must reflect reality) and the
286
279
  // epoch always advances via the real insert; only the bucket:entered emission
287
- // is gated by the reentry policy (Section 6.3).
288
- if (shouldEmitJoin(bucket, priorCount)) {
280
+ // is gated by the entryLimit policy (Section 6.3).
281
+ if (await shouldEmitJoin({ db, bucket, userId, priorCount })) {
289
282
  await emitBucketTransition({
290
283
  db,
291
284
  registry,
@@ -299,10 +292,10 @@ async function handleJoin(opts: {
299
292
  source: "event",
300
293
  });
301
294
  } else {
302
- logger.info("Bucket join emit suppressed by reentry policy", {
295
+ logger.info("Bucket join emit suppressed by entryLimit policy", {
303
296
  bucketId: bucket.id,
304
297
  userId,
305
- reentry: bucket.reentry ?? "unlimited",
298
+ entryLimit: bucket.entryLimit ?? "unlimited",
306
299
  });
307
300
  }
308
301
 
@@ -392,28 +385,56 @@ async function handleLeave(opts: {
392
385
  }
393
386
 
394
387
  /**
395
- * The `reentry` emit gate, consulted on the JOIN transition only (Section 6.3).
388
+ * The `entryLimit` emit gate, consulted on the JOIN transition only (Section 6.3).
396
389
  * Suppressing the emit still wrote the active row and advanced the epoch — only
397
390
  * the `bucket:entered` ingestEvent recursion is skipped.
391
+ *
392
+ * The engine now enforces `once_per_period` PRECISELY: it reads the most-recent
393
+ * prior LEAVE (`status:"left"` with `leftAt` set) and emits only once the
394
+ * configured `entryPeriod` has elapsed since that leave. The journey-side
395
+ * entryLimit/entryPeriod is a redundant backstop, no longer the sole gate.
398
396
  */
399
- function shouldEmitJoin(bucket: BucketMeta, priorCount: number): boolean {
397
+ export async function shouldEmitJoin(opts: {
398
+ db: Database;
399
+ bucket: BucketMeta;
400
+ userId: string;
401
+ priorCount: number;
402
+ }): Promise<boolean> {
403
+ const { db, bucket, userId, priorCount } = opts;
400
404
  // First-ever join always emits.
401
405
  if (priorCount === 0) return true;
402
- switch (bucket.reentry ?? "unlimited") {
406
+ switch (bucket.entryLimit ?? "unlimited") {
403
407
  case "unlimited":
404
408
  return true;
405
409
  case "once":
406
410
  // Any prior membership → suppress (mirrors checkEntryLimit "once").
407
411
  return false;
408
- case "once_per_period":
409
- // Conservative without re-reading prior rows here: a per-period emit gate
410
- // needs the most-recent prior transition timestamp. The active row was
411
- // just inserted; the prior-row timestamps are not loaded on this path, so
412
- // we treat once_per_period as "emit" once the active insert won and let
413
- // the journey-side entryLimit/entryPeriod enforce cooldown (the documented
414
- // two-layer gating, Section 13). A precise prior-transition lookup is a
415
- // later-phase refinement.
416
- return true;
412
+ case "once_per_period": {
413
+ // Back-compat: with no period configured, preserve 0.2.0 behavior (emit)
414
+ // and defer cooldown to the journey-side entryLimit/entryPeriod.
415
+ if (!bucket.entryPeriod) return true;
416
+ // Look up the most-recent COMPLETED prior cycle. Scoping to status:"left"
417
+ // (not "any prior row") makes this order-independent and race-safe against
418
+ // the active row we just inserted at this join — that row has no leftAt and
419
+ // status:"active", so it can never be mistaken for the prior cycle.
420
+ const [prior] = await db
421
+ .select({ leftAt: bucketMemberships.leftAt })
422
+ .from(bucketMemberships)
423
+ .where(
424
+ and(
425
+ eq(bucketMemberships.userId, userId),
426
+ eq(bucketMemberships.bucketId, bucket.id),
427
+ eq(bucketMemberships.status, "left"),
428
+ isNotNull(bucketMemberships.leftAt),
429
+ ),
430
+ )
431
+ .orderBy(desc(bucketMemberships.leftAt))
432
+ .limit(1);
433
+ // No completed prior cycle to cool off from → emit.
434
+ if (!prior?.leftAt) return true;
435
+ const elapsed = Date.now() - prior.leftAt.getTime();
436
+ return elapsed >= durationToMs(bucket.entryPeriod);
437
+ }
417
438
  default:
418
439
  return true;
419
440
  }
@@ -467,33 +488,3 @@ async function armExpiryTimer(opts: {
467
488
  });
468
489
  }
469
490
  }
470
-
471
- /**
472
- * The persisted membership-expiry / fastExpiry arming epoch. Non-time-based,
473
- * non-fastExpiry buckets have no deadline (returns null). Time-based / fastExpiry
474
- * buckets that carry a single `within` window get `now + within`; the reconcile
475
- * cron (Phase 2) and fastExpiry timer (Phase 3) own the actual leave.
476
- */
477
- function computeExpiresAt(bucket: BucketMeta): Date | null {
478
- if (!bucket.criteria) return null;
479
- if (!bucket.timeBased && !bucket.fastExpiry) return null;
480
- const within = firstWithin(bucket.criteria);
481
- if (!within) return null;
482
- return new Date(Date.now() + durationToMs(within));
483
- }
484
-
485
- /** Find the first EventCondition.within in a criteria tree (depth-first). */
486
- function firstWithin(
487
- criteria: NonNullable<BucketMeta["criteria"]>,
488
- ): NonNullable<BucketMeta["minDwell"]> | null {
489
- if (criteria.type === "event" && criteria.within) {
490
- return criteria.within;
491
- }
492
- if (criteria.type === "composite") {
493
- for (const child of criteria.conditions) {
494
- const found = firstWithin(child);
495
- if (found) return found;
496
- }
497
- }
498
- return null;
499
- }
@@ -1,6 +1,24 @@
1
- import type { BucketMeta } from "@hogsend/core/types";
1
+ import { type CriteriaBuilder, criteriaBuilder } from "@hogsend/core";
2
+ import type { BucketMeta, ConditionEval } from "@hogsend/core/types";
2
3
  import type { hatchet } from "../lib/hatchet.js";
3
4
 
5
+ /**
6
+ * `criteria` may be authored two ways:
7
+ * - declaratively, as a `ConditionEval` data tree, or
8
+ * - with the fluent builder, as `(b) => b.all(b.prop("plan").eq("trial"), ...)`.
9
+ * The builder form runs ONCE here and returns a `ConditionEval`, so everything
10
+ * downstream (registry indexes, schema validation, reconcile cron, Studio) only
11
+ * ever sees the canonical declarative data.
12
+ */
13
+ export type CriteriaInput =
14
+ | ConditionEval
15
+ | ((b: CriteriaBuilder) => ConditionEval);
16
+
17
+ /** `BucketMeta` as authored — `criteria` accepts the builder function too. */
18
+ export type BucketMetaInput = Omit<BucketMeta, "criteria"> & {
19
+ criteria?: CriteriaInput;
20
+ };
21
+
4
22
  export interface DefinedBucket {
5
23
  meta: BucketMeta;
6
24
  /**
@@ -15,15 +33,20 @@ export interface DefinedBucket {
15
33
  task?: ReturnType<typeof hatchet.durableTask>;
16
34
  }
17
35
 
18
- export function defineBucket(options: { meta: BucketMeta }): DefinedBucket {
19
- // bucketMetaSchema.parse happens at BucketRegistry.register (the journey
20
- // precedent). defineBucket stays a PURE passthrough — identical in shape to
21
- // defineWebhookSource (define-webhook-source.ts:30-34) and does NOT branch
22
- // on meta or construct any task. This keeps the three primitives consistent
23
- // and avoids building a Hatchet durableTask at module-load before validation
24
- // has run. The fast-expiry durableTask is synthesized later, at worker build,
25
- // by selectBucketTasks(buckets, enabled) reading meta.fastExpiry (Section 9.4)
26
- // that is the single place a bucket's task is constructed, AFTER the
27
- // registry has validated the meta.
28
- return { meta: options.meta };
36
+ export function defineBucket(options: {
37
+ meta: BucketMetaInput;
38
+ }): DefinedBucket {
39
+ // The ONLY transform defineBucket performs is resolving a builder-function
40
+ // `criteria` to its `ConditionEval` (a one-shot, definition-time call). It does
41
+ // NOT validate or build any task `bucketMetaSchema.parse` still happens at
42
+ // BucketRegistry.register, and the fast-expiry durableTask is synthesized later
43
+ // by selectBucketTasks (Section 9.4). A declarative `criteria` passes straight
44
+ // through unchanged, so existing buckets are unaffected.
45
+ const { criteria, ...rest } = options.meta;
46
+ const meta: BucketMeta = {
47
+ ...rest,
48
+ criteria:
49
+ typeof criteria === "function" ? criteria(criteriaBuilder) : criteria,
50
+ };
51
+ return { meta };
29
52
  }
@@ -0,0 +1,186 @@
1
+ import {
2
+ type BucketMeta,
3
+ type ConditionEval,
4
+ type DurationObject,
5
+ durationToMs,
6
+ type EventCondition,
7
+ } from "@hogsend/core";
8
+ import { bucketMemberships, type Database } from "@hogsend/db";
9
+ import { and, eq, sql } from "drizzle-orm";
10
+
11
+ /**
12
+ * The reserved prefix every bucket transition event carries. Single source of
13
+ * truth shared by the ingest recursion guard (`checkBucketMembership`) and the
14
+ * fast-expiry arming event (`bucket:arm-expiry`).
15
+ */
16
+ export const BUCKET_EVENT_PREFIX = "bucket:";
17
+
18
+ /**
19
+ * The count of ALL prior memberships (active + left, NO status filter) for a
20
+ * (userId, bucketId) pair. This is the single source for the entryCount-ordinal
21
+ * rule (Section 6.3 / 8.2): the next epoch is `1 + countPriorMemberships(...)`,
22
+ * and the same count drives the entryLimit gate. Both the real-time join path
23
+ * (`handleJoin`) and the reconcile-discovered join path (`reconcileJoinOne`)
24
+ * call this so the ordinal can never drift between the two writers.
25
+ *
26
+ * The predicate MUST stay status-agnostic — narrowing it (e.g. to active-only)
27
+ * would corrupt entryCount and the entryLimit cooldown.
28
+ */
29
+ export async function countPriorMemberships(
30
+ db: Database,
31
+ bucketId: string,
32
+ userId: string,
33
+ ): Promise<number> {
34
+ const [counted] = await db
35
+ .select({ priorCount: sql<number>`count(*)::int` })
36
+ .from(bucketMemberships)
37
+ .where(
38
+ and(
39
+ eq(bucketMemberships.userId, userId),
40
+ eq(bucketMemberships.bucketId, bucketId),
41
+ ),
42
+ );
43
+ return Number(counted?.priorCount ?? 0);
44
+ }
45
+
46
+ /**
47
+ * Find the first `EventCondition.within` rolling window in a criteria tree
48
+ * (depth-first). Returns null when no event leg carries a window. The single
49
+ * source for the membership-expiry / fastExpiry deadline math — shared so the
50
+ * three membership writers (real-time join, reconcile join, backfill join) can
51
+ * never disagree on which window drives `expiresAt`.
52
+ */
53
+ export function firstWithin(criteria: ConditionEval): DurationObject | null {
54
+ if (criteria.type === "event" && criteria.within) {
55
+ return criteria.within;
56
+ }
57
+ if (criteria.type === "composite") {
58
+ for (const child of criteria.conditions) {
59
+ const found = firstWithin(child);
60
+ if (found) return found;
61
+ }
62
+ }
63
+ return null;
64
+ }
65
+
66
+ /**
67
+ * The persisted membership-expiry / fastExpiry arming deadline. Non-time-based,
68
+ * non-fastExpiry buckets have no deadline (returns null). Time-based / fastExpiry
69
+ * buckets that carry a single `within` window get `now + within`; the reconcile
70
+ * cron and fastExpiry timer own the actual leave.
71
+ *
72
+ * Centralized so the real-time join (check-membership.ts), the reconcile-discovered
73
+ * join (bucket-reconcile.ts), and the backfill join (bucket-backfill.ts) compute
74
+ * the SAME deadline — a divergence here would let a membership-writer arm a window
75
+ * the cron/timer disagrees with.
76
+ */
77
+ export function computeExpiresAt(bucket: BucketMeta): Date | null {
78
+ if (!bucket.criteria) return null;
79
+ if (!bucket.timeBased && !bucket.fastExpiry) return null;
80
+ const within = firstWithin(bucket.criteria);
81
+ if (!within) return null;
82
+ return new Date(Date.now() + durationToMs(within));
83
+ }
84
+
85
+ /**
86
+ * The unconditional max-dwell TTL deadline, stamped once on JOIN. null when the
87
+ * bucket has no `maxDwell`; the TTL sweep filters `isNotNull(maxDwellAt)`, so an
88
+ * unset value is never force-left. Shared by all three join writers so the TTL
89
+ * stamp is computed identically.
90
+ */
91
+ export function computeMaxDwellAt(bucket: BucketMeta): Date | null {
92
+ return bucket.maxDwell
93
+ ? new Date(Date.now() + durationToMs(bucket.maxDwell))
94
+ : null;
95
+ }
96
+
97
+ /**
98
+ * The shared windowed event-count operator comparison kernel (`gt/gte/lt/lte/eq`).
99
+ * Returns the boolean of `count <operator> value`, or null for an unrecognized
100
+ * operator so each caller keeps its own default (the leave/match wrappers below).
101
+ * This is the single source for the count-operator math the reconcile SHOULD-LEAVE
102
+ * and the backfill MATCH paths both depend on.
103
+ */
104
+ function compareCount(
105
+ operator: NonNullable<EventCondition["operator"]>,
106
+ count: number,
107
+ value: number,
108
+ ): boolean | null {
109
+ switch (operator) {
110
+ case "gt":
111
+ return count > value;
112
+ case "gte":
113
+ return count >= value;
114
+ case "lt":
115
+ return count < value;
116
+ case "lte":
117
+ return count <= value;
118
+ case "eq":
119
+ return count === value;
120
+ default:
121
+ return null;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * True when a windowed `count` satisfies the (exists/not_exists/count) event
127
+ * criterion — the POSITIVE "is a member by this windowed count" decision. Shared
128
+ * by the backfill matcher path (selectEventMatchers' positive branch). Behavior is
129
+ * identical to the prior local `matchesCount`: a `count` check with no
130
+ * operator/value (or an unrecognized operator) falls back to `count > 0` / `false`
131
+ * respectively.
132
+ */
133
+ export function matchesEventCount(
134
+ criteria: EventCondition,
135
+ count: number,
136
+ ): boolean {
137
+ switch (criteria.check) {
138
+ case "exists":
139
+ return count > 0;
140
+ case "not_exists":
141
+ return count === 0;
142
+ case "count": {
143
+ if (!criteria.operator || criteria.value === undefined) return count > 0;
144
+ const result = compareCount(criteria.operator, count, criteria.value);
145
+ return result ?? false;
146
+ }
147
+ default:
148
+ return false;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * The SHOULD-LEAVE decision from a windowed count, per criterion shape — a member
154
+ * leaves when the criterion is NO LONGER satisfied. Shared by the reconcile
155
+ * cron's set-based leave path. Behavior is identical to the prior local
156
+ * `shouldLeaveByCount`: it is the per-shape NEGATION of {@link matchesEventCount}
157
+ * for `exists`/`count`, an event-reappeared check for `not_exists`, and preserves
158
+ * the `count` `default → false` for an unrecognized operator.
159
+ */
160
+ export function shouldLeaveByCount(
161
+ criteria: EventCondition,
162
+ windowedCount: number,
163
+ ): boolean {
164
+ switch (criteria.check) {
165
+ case "not_exists":
166
+ // Absence bucket: SHOULD LEAVE when an event REAPPEARS in the window.
167
+ return windowedCount > 0;
168
+ case "exists":
169
+ // Positive existence: SHOULD LEAVE when NOT EXISTS in the window.
170
+ return windowedCount === 0;
171
+ case "count": {
172
+ // SHOULD LEAVE when the windowed count NO LONGER satisfies the operator.
173
+ if (!criteria.operator || criteria.value === undefined) {
174
+ return windowedCount === 0;
175
+ }
176
+ const result = compareCount(
177
+ criteria.operator,
178
+ windowedCount,
179
+ criteria.value,
180
+ );
181
+ return result == null ? false : !result;
182
+ }
183
+ default:
184
+ return false;
185
+ }
186
+ }
package/src/index.ts CHANGED
@@ -54,6 +54,7 @@ export {
54
54
  type DefinedJourney,
55
55
  defineJourney,
56
56
  } from "./journeys/define-journey.js";
57
+ export { JourneyExitedError } from "./journeys/errors.js";
57
58
  export { createJourneyContext } from "./journeys/journey-context.js";
58
59
  export {
59
60
  buildJourneyRegistry,
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Max active execution time configured on every journey durable task
3
+ * (`executionTimeout`). It is the single source of truth shared by
4
+ * `define-journey` (the task config) and `journey-context` (the `waitForEvent`
5
+ * timeout ceiling) so the two never drift.
6
+ *
7
+ * NOTE: on eviction-capable Hatchet engines (>= v0.80.0) a durable wait evicts
8
+ * the task and frees the worker slot, so a very long wall-clock wait MAY exceed
9
+ * this. We still treat it as our ceiling: `waitForEvent` rejects timeouts beyond
10
+ * it so they fail fast at authoring time rather than risk a mid-wait
11
+ * termination. Raise this to allow longer waits.
12
+ */
13
+ export const JOURNEY_EXECUTION_TIMEOUT_HOURS = 720;
14
+ export const JOURNEY_EXECUTION_TIMEOUT = `${JOURNEY_EXECUTION_TIMEOUT_HOURS}h`;
@@ -6,7 +6,7 @@ import type {
6
6
  JourneyUser,
7
7
  } from "@hogsend/core/types";
8
8
  import { contacts, journeyConfigs, journeyStates } from "@hogsend/db";
9
- import { and, eq, inArray } from "drizzle-orm";
9
+ import { and, eq, inArray, notInArray } from "drizzle-orm";
10
10
  import { getDb } from "../lib/db.js";
11
11
  import {
12
12
  checkEmailPreferences,
@@ -17,7 +17,9 @@ import { createLogger } from "../lib/logger.js";
17
17
  import { getPostHog } from "../lib/posthog.js";
18
18
  import { resolveTimezoneWithSource } from "../lib/timezone.js";
19
19
  import { getClientScheduleDefaults } from "./client-defaults-singleton.js";
20
- import { createJourneyContext } from "./journey-context.js";
20
+ import { JOURNEY_EXECUTION_TIMEOUT } from "./constants.js";
21
+ import { JourneyExitedError } from "./errors.js";
22
+ import { createJourneyContext, TERMINAL_STATUSES } from "./journey-context.js";
21
23
  import { getJourneyRegistrySingleton } from "./registry-singleton.js";
22
24
 
23
25
  const logger = createLogger(process.env.LOG_LEVEL);
@@ -43,7 +45,7 @@ export function defineJourney(options: {
43
45
  const task = hatchet.durableTask({
44
46
  name: `journey-${meta.id}`,
45
47
  onEvents: [meta.trigger.event],
46
- executionTimeout: "720h",
48
+ executionTimeout: JOURNEY_EXECUTION_TIMEOUT,
47
49
  retries: 0,
48
50
  fn: async (input: EventPayloadInput, hatchetCtx) => {
49
51
  const db = getDb();
@@ -203,17 +205,42 @@ export function defineJourney(options: {
203
205
 
204
206
  return { stateId, status: "completed" };
205
207
  } catch (err) {
208
+ // The journey reached a terminal state (exitOn / cancel) while suspended
209
+ // in a durable wait. The state row is already terminal — stop gracefully
210
+ // without marking it "failed" or re-pushing a journey:failed event.
211
+ if (err instanceof JourneyExitedError) {
212
+ return { stateId, status: "exited" };
213
+ }
214
+
206
215
  const message =
207
216
  err instanceof Error ? err.message : "Unknown error during journey";
208
217
 
209
- await db
218
+ // Mark "failed" ONLY if the row isn't already terminal. A run cancelled
219
+ // by exitOn (ingestEvent sets "exited" then `runs.cancel`) or by the
220
+ // admin route surfaces here as a Hatchet AbortError thrown from the
221
+ // suspended waitFor/sleepFor — NOT a JourneyExitedError. Guarding on a
222
+ // non-terminal status prevents clobbering that "exited" row to "failed"
223
+ // and emitting a spurious journey:failed event.
224
+ const [failed] = await db
210
225
  .update(journeyStates)
211
226
  .set({
212
227
  status: "failed",
213
228
  errorMessage: message,
214
229
  updatedAt: new Date(),
215
230
  })
216
- .where(eq(journeyStates.id, stateId));
231
+ .where(
232
+ and(
233
+ eq(journeyStates.id, stateId),
234
+ notInArray(journeyStates.status, [...TERMINAL_STATUSES]),
235
+ ),
236
+ )
237
+ .returning({ id: journeyStates.id });
238
+
239
+ if (!failed) {
240
+ // Already terminal (cancelled after exit) — swallow the cancellation
241
+ // so the run doesn't double-report as failed.
242
+ return { stateId, status: "exited" };
243
+ }
217
244
 
218
245
  await hatchet.events.push("journey:failed", {
219
246
  journeyId: meta.id,
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Thrown by durable wait primitives (e.g. `ctx.waitForEvent`) when the journey
3
+ * reached a terminal state — exited via `exitOn`, or cancelled — while it was
4
+ * suspended. It is a CONTROL-FLOW SIGNAL, not a failure: `defineJourney` catches
5
+ * it and stops the run gracefully WITHOUT marking the state `"failed"` (the row
6
+ * is already terminal). Consumers generally never observe it; it simply aborts
7
+ * `run()` before any post-wait side effect can fire.
8
+ */
9
+ export class JourneyExitedError extends Error {
10
+ readonly stateId: string;
11
+
12
+ constructor(stateId: string) {
13
+ super(`Journey state ${stateId} is no longer active (exited or cancelled)`);
14
+ this.name = "JourneyExitedError";
15
+ this.stateId = stateId;
16
+ }
17
+ }