@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 +6 -6
- package/src/buckets/check-membership.ts +57 -66
- package/src/buckets/define-bucket.ts +35 -12
- package/src/buckets/membership-epoch.ts +186 -0
- package/src/index.ts +1 -0
- package/src/journeys/constants.ts +14 -0
- package/src/journeys/define-journey.ts +32 -5
- package/src/journeys/errors.ts +17 -0
- package/src/journeys/journey-context.ts +134 -17
- package/src/lib/ingestion.ts +22 -1
- package/src/routes/admin/buckets.ts +5 -7
- package/src/worker.ts +7 -5
- package/src/workflows/bucket-backfill.ts +117 -80
- package/src/workflows/bucket-reconcile.ts +420 -131
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/engine",
|
|
3
|
-
"version": "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.
|
|
39
|
-
"@hogsend/db": "^0.
|
|
40
|
-
"@hogsend/email": "^0.
|
|
41
|
-
"@hogsend/plugin-posthog": "^0.
|
|
42
|
-
"@hogsend/plugin-resend": "^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,
|
|
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
|
|
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
|
|
225
|
-
// gate.
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
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
|
|
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
|
|
295
|
+
logger.info("Bucket join emit suppressed by entryLimit policy", {
|
|
303
296
|
bucketId: bucket.id,
|
|
304
297
|
userId,
|
|
305
|
-
|
|
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 `
|
|
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(
|
|
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.
|
|
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
|
-
//
|
|
410
|
-
//
|
|
411
|
-
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
|
|
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
|
|
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: {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// by selectBucketTasks
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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
|
+
}
|