@hogsend/engine 0.3.0 → 0.5.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/README.md +8 -0
- package/package.json +9 -7
- package/src/app.ts +1 -1
- package/src/buckets/registry-singleton.ts +5 -17
- package/src/container.ts +13 -5
- package/src/env.ts +7 -0
- package/src/index.ts +25 -0
- package/src/journeys/constants.ts +14 -0
- package/src/journeys/define-journey.ts +36 -7
- package/src/journeys/errors.ts +17 -0
- package/src/journeys/journey-context.ts +135 -19
- package/src/journeys/registry-singleton.ts +5 -17
- package/src/lib/alerting.ts +0 -4
- package/src/lib/analytics-singleton.ts +25 -0
- package/src/lib/boot.ts +166 -0
- package/src/lib/bucket-posthog-sync.ts +6 -4
- package/src/lib/email-service-types.ts +19 -10
- package/src/lib/email.ts +15 -13
- package/src/lib/ingestion.ts +22 -1
- package/src/lib/mailer.ts +9 -9
- package/src/lib/notifications.ts +7 -8
- package/src/lib/posthog.ts +2 -4
- package/src/lib/singleton.ts +56 -0
- package/src/lib/tracked.ts +1 -1
- package/src/lib/tracking-events.ts +1 -1
- package/src/lib/worker-heartbeat.ts +78 -0
- package/src/routes/health.ts +16 -1
- package/src/worker.ts +28 -5
- package/src/workflows/check-alerts.ts +0 -1
- package/src/workflows/send-email.ts +35 -33
package/README.md
CHANGED
|
@@ -5,6 +5,14 @@ The Hogsend framework: `createApp`, `createHogsendClient`, `createWorker`,
|
|
|
5
5
|
built-in routes, and the registries. This is the public API surface consumers
|
|
6
6
|
build their lifecycle apps on top of.
|
|
7
7
|
|
|
8
|
+
The engine also re-exports the capability-provider contracts owned by
|
|
9
|
+
`@hogsend/core` — `EmailProvider` and `PostHogService` (plus their supporting
|
|
10
|
+
types) — so `@hogsend/engine` is the **canonical author import** for them when
|
|
11
|
+
writing a custom provider. (The contract-level `SendEmailOptions` is the one
|
|
12
|
+
exception: it stays on `@hogsend/core`, since the engine exports a different,
|
|
13
|
+
higher-level `SendEmailOptions`.) See
|
|
14
|
+
[docs/adr/0001-provider-boundary.md](https://github.com/dougwithseismic/hogsend/blob/main/docs/adr/0001-provider-boundary.md).
|
|
15
|
+
|
|
8
16
|
Scaffold a consumer app with `pnpm dlx create-hogsend@latest`. The engine line
|
|
9
17
|
(`@hogsend/engine`, `@hogsend/db`, `@hogsend/core`) is versioned in lockstep so a
|
|
10
18
|
new engine migration always bumps the engine and DB together; the boot guard
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
"types": "./src/index.ts",
|
|
13
13
|
"exports": {
|
|
14
14
|
".": "./src/index.ts",
|
|
15
|
-
"./worker": "./src/worker.ts"
|
|
15
|
+
"./worker": "./src/worker.ts",
|
|
16
|
+
"./package.json": "./package.json"
|
|
16
17
|
},
|
|
17
18
|
"files": [
|
|
18
19
|
"src",
|
|
@@ -32,14 +33,15 @@
|
|
|
32
33
|
"hono": "^4.12.22",
|
|
33
34
|
"ioredis": "^5.10.1",
|
|
34
35
|
"papaparse": "^5.5.3",
|
|
36
|
+
"picocolors": "^1.1.1",
|
|
35
37
|
"resend": "^6.12.3",
|
|
36
38
|
"winston": "^3.19.0",
|
|
37
39
|
"zod": "^4.4.3",
|
|
38
|
-
"@hogsend/core": "^0.
|
|
39
|
-
"@hogsend/
|
|
40
|
-
"@hogsend/
|
|
41
|
-
"@hogsend/plugin-posthog": "^0.
|
|
42
|
-
"@hogsend/plugin-resend": "^0.
|
|
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"
|
|
43
45
|
},
|
|
44
46
|
"devDependencies": {
|
|
45
47
|
"@types/node": "^22.15.3",
|
package/src/app.ts
CHANGED
|
@@ -103,7 +103,7 @@ export function createApp(
|
|
|
103
103
|
// No-op when no built dist is present, so an unbuilt studio never crashes boot.
|
|
104
104
|
const studio = mountStudio(app);
|
|
105
105
|
if (studio.mounted) {
|
|
106
|
-
container.logger.
|
|
106
|
+
container.logger.debug(
|
|
107
107
|
`Studio mounted at /studio (dist: ${studio.distPath})`,
|
|
108
108
|
);
|
|
109
109
|
}
|
|
@@ -1,21 +1,9 @@
|
|
|
1
1
|
import type { BucketRegistry } from "@hogsend/core/registry";
|
|
2
|
+
import { createSingleton } from "../lib/singleton.js";
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
export function setBucketRegistry(registry: BucketRegistry): void {
|
|
6
|
-
_registry = registry;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export function getBucketRegistrySingleton(): BucketRegistry {
|
|
10
|
-
if (!_registry) {
|
|
11
|
-
throw new Error(
|
|
12
|
-
"Bucket registry not initialized. Call setBucketRegistry() at startup.",
|
|
13
|
-
);
|
|
14
|
-
}
|
|
15
|
-
return _registry;
|
|
16
|
-
}
|
|
4
|
+
const singleton = createSingleton<BucketRegistry>("Bucket registry");
|
|
17
5
|
|
|
6
|
+
export const setBucketRegistry = singleton.set;
|
|
7
|
+
export const getBucketRegistrySingleton = singleton.get;
|
|
18
8
|
/** Reset the singleton — only for test cleanup. */
|
|
19
|
-
export
|
|
20
|
-
_registry = undefined;
|
|
21
|
-
}
|
|
9
|
+
export const resetBucketRegistry = singleton.reset;
|
package/src/container.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
|
|
2
|
-
import type { TimeZone } from "@hogsend/core";
|
|
2
|
+
import type { EmailProvider, PostHogService, TimeZone } from "@hogsend/core";
|
|
3
3
|
import type { BucketRegistry, JourneyRegistry } from "@hogsend/core/registry";
|
|
4
4
|
import type { SendWindow } from "@hogsend/core/schedule";
|
|
5
5
|
import {
|
|
@@ -9,11 +9,9 @@ import {
|
|
|
9
9
|
type JournalShape,
|
|
10
10
|
} from "@hogsend/db";
|
|
11
11
|
import type { TemplateRegistry } from "@hogsend/email";
|
|
12
|
-
import type { PostHogService } from "@hogsend/plugin-posthog";
|
|
13
12
|
import {
|
|
14
13
|
createResendClient,
|
|
15
14
|
createResendProvider,
|
|
16
|
-
type EmailProvider,
|
|
17
15
|
} from "@hogsend/plugin-resend";
|
|
18
16
|
import type { Resend } from "resend";
|
|
19
17
|
import type { DefinedBucket } from "./buckets/define-bucket.js";
|
|
@@ -22,6 +20,7 @@ import { env } from "./env.js";
|
|
|
22
20
|
import { setClientScheduleDefaults } from "./journeys/client-defaults-singleton.js";
|
|
23
21
|
import type { DefinedJourney } from "./journeys/define-journey.js";
|
|
24
22
|
import { buildJourneyRegistry } from "./journeys/registry.js";
|
|
23
|
+
import { setAnalytics } from "./lib/analytics-singleton.js";
|
|
25
24
|
import { type Auth, createAuth } from "./lib/auth.js";
|
|
26
25
|
import { setEmailService } from "./lib/email.js";
|
|
27
26
|
import type {
|
|
@@ -251,8 +250,17 @@ export function createHogsendClient(
|
|
|
251
250
|
|
|
252
251
|
const analytics = opts.analytics ?? getPostHog();
|
|
253
252
|
|
|
254
|
-
|
|
255
|
-
|
|
253
|
+
// Expose the resolved analytics instance to the module-level task-execution
|
|
254
|
+
// sites that have no client reference (the journey durable task in
|
|
255
|
+
// define-journey, the bucket PostHog sync). `createHogsendClient` runs in both
|
|
256
|
+
// the API and worker, so this is installed before any worker task runs. May be
|
|
257
|
+
// undefined (no POSTHOG_API_KEY) — the reads stay no-ops.
|
|
258
|
+
setAnalytics(analytics);
|
|
259
|
+
|
|
260
|
+
// Counts are surfaced by the boot banner / structured ready log (lib/boot.ts);
|
|
261
|
+
// keep these at debug for non-boot contexts (tests, REPL, library use).
|
|
262
|
+
logger.debug(`Journey registry loaded: ${registry.count()} journeys`);
|
|
263
|
+
logger.debug(`Bucket registry loaded: ${bucketRegistry.count()} buckets`);
|
|
256
264
|
|
|
257
265
|
return {
|
|
258
266
|
env,
|
package/src/env.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { createEnv } from "@t3-oss/env-core";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* The HTTP API contract version — surfaced in the OpenAPI document
|
|
6
|
+
* (`info.version`) and the `GET /v1/health` body. This is the WIRE contract
|
|
7
|
+
* version and is independent of the `@hogsend/engine` npm package version: bump
|
|
8
|
+
* it only on an API-breaking change (a new `/vN` route family), NOT on every
|
|
9
|
+
* package release. The `/v1` route prefix is hardcoded separately.
|
|
10
|
+
*/
|
|
4
11
|
export const API_VERSION = "0.0.1";
|
|
5
12
|
|
|
6
13
|
export const env = createEnv({
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,22 @@
|
|
|
3
3
|
// Content (journeys, webhook sources, workflows) is injected into these
|
|
4
4
|
// factories by client app code; the engine never imports content.
|
|
5
5
|
|
|
6
|
+
// --- Capability-provider contracts (canonical origin: @hogsend/core) ---
|
|
7
|
+
// Email provider contract + analytics contract, re-exported so consumers can
|
|
8
|
+
// import them from `@hogsend/engine`. (`SendEmailOptions` is intentionally
|
|
9
|
+
// omitted here: the engine's public `SendEmailOptions` is the high-level
|
|
10
|
+
// journey-facing send options from `./lib/email.js`; the provider-contract
|
|
11
|
+
// `SendEmailOptions` remains available via `@hogsend/core`.)
|
|
12
|
+
export type {
|
|
13
|
+
BatchEmailItem,
|
|
14
|
+
CaptureOptions,
|
|
15
|
+
EmailProvider,
|
|
16
|
+
PostHogService,
|
|
17
|
+
SendResult,
|
|
18
|
+
WebhookEvent,
|
|
19
|
+
WebhookEventType,
|
|
20
|
+
WebhookHandlerMap,
|
|
21
|
+
} from "@hogsend/core";
|
|
6
22
|
// Core helpers used by content journeys (days/hours/minutes, condition + journey
|
|
7
23
|
// types) so content can import everything from `@hogsend/engine`.
|
|
8
24
|
export * from "@hogsend/core";
|
|
@@ -54,6 +70,7 @@ export {
|
|
|
54
70
|
type DefinedJourney,
|
|
55
71
|
defineJourney,
|
|
56
72
|
} from "./journeys/define-journey.js";
|
|
73
|
+
export { JourneyExitedError } from "./journeys/errors.js";
|
|
57
74
|
export { createJourneyContext } from "./journeys/journey-context.js";
|
|
58
75
|
export {
|
|
59
76
|
buildJourneyRegistry,
|
|
@@ -72,6 +89,14 @@ export {
|
|
|
72
89
|
type BatchedBackfillResult,
|
|
73
90
|
runBatchedBackfill,
|
|
74
91
|
} from "./lib/backfill.js";
|
|
92
|
+
// --- Boot output (engine-owned startup banner / structured ready log) ---
|
|
93
|
+
export {
|
|
94
|
+
type ApiReadyInfo,
|
|
95
|
+
getEngineVersion,
|
|
96
|
+
reportApiReady,
|
|
97
|
+
reportWorkerReady,
|
|
98
|
+
type WorkerReadyInfo,
|
|
99
|
+
} from "./lib/boot.js";
|
|
75
100
|
// --- Bucket transition emission (shared by real-time / cron / fast-expiry) ---
|
|
76
101
|
export {
|
|
77
102
|
type BucketTransitionSource,
|
|
@@ -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,8 @@ 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
|
+
import { getAnalytics } from "../lib/analytics-singleton.js";
|
|
10
11
|
import { getDb } from "../lib/db.js";
|
|
11
12
|
import {
|
|
12
13
|
checkEmailPreferences,
|
|
@@ -14,10 +15,11 @@ import {
|
|
|
14
15
|
} from "../lib/enrollment-guards.js";
|
|
15
16
|
import { hatchet } from "../lib/hatchet.js";
|
|
16
17
|
import { createLogger } from "../lib/logger.js";
|
|
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();
|
|
@@ -129,7 +131,9 @@ export function defineJourney(options: {
|
|
|
129
131
|
journeyName: meta.name,
|
|
130
132
|
};
|
|
131
133
|
|
|
132
|
-
|
|
134
|
+
// The injected analytics instance (set by createHogsendClient). Same
|
|
135
|
+
// object as container.analytics; undefined when POSTHOG_API_KEY is unset.
|
|
136
|
+
const posthog = getAnalytics();
|
|
133
137
|
const scheduleDefaults = getClientScheduleDefaults();
|
|
134
138
|
|
|
135
139
|
// Resolve the user's timezone via the precedence chain (explicit is N/A
|
|
@@ -203,17 +207,42 @@ export function defineJourney(options: {
|
|
|
203
207
|
|
|
204
208
|
return { stateId, status: "completed" };
|
|
205
209
|
} catch (err) {
|
|
210
|
+
// The journey reached a terminal state (exitOn / cancel) while suspended
|
|
211
|
+
// in a durable wait. The state row is already terminal — stop gracefully
|
|
212
|
+
// without marking it "failed" or re-pushing a journey:failed event.
|
|
213
|
+
if (err instanceof JourneyExitedError) {
|
|
214
|
+
return { stateId, status: "exited" };
|
|
215
|
+
}
|
|
216
|
+
|
|
206
217
|
const message =
|
|
207
218
|
err instanceof Error ? err.message : "Unknown error during journey";
|
|
208
219
|
|
|
209
|
-
|
|
220
|
+
// Mark "failed" ONLY if the row isn't already terminal. A run cancelled
|
|
221
|
+
// by exitOn (ingestEvent sets "exited" then `runs.cancel`) or by the
|
|
222
|
+
// admin route surfaces here as a Hatchet AbortError thrown from the
|
|
223
|
+
// suspended waitFor/sleepFor — NOT a JourneyExitedError. Guarding on a
|
|
224
|
+
// non-terminal status prevents clobbering that "exited" row to "failed"
|
|
225
|
+
// and emitting a spurious journey:failed event.
|
|
226
|
+
const [failed] = await db
|
|
210
227
|
.update(journeyStates)
|
|
211
228
|
.set({
|
|
212
229
|
status: "failed",
|
|
213
230
|
errorMessage: message,
|
|
214
231
|
updatedAt: new Date(),
|
|
215
232
|
})
|
|
216
|
-
.where(
|
|
233
|
+
.where(
|
|
234
|
+
and(
|
|
235
|
+
eq(journeyStates.id, stateId),
|
|
236
|
+
notInArray(journeyStates.status, [...TERMINAL_STATUSES]),
|
|
237
|
+
),
|
|
238
|
+
)
|
|
239
|
+
.returning({ id: journeyStates.id });
|
|
240
|
+
|
|
241
|
+
if (!failed) {
|
|
242
|
+
// Already terminal (cancelled after exit) — swallow the cancellation
|
|
243
|
+
// so the run doesn't double-report as failed.
|
|
244
|
+
return { stateId, status: "exited" };
|
|
245
|
+
}
|
|
217
246
|
|
|
218
247
|
await hatchet.events.push("journey:failed", {
|
|
219
248
|
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
|
+
}
|
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import type {
|
|
2
|
+
Conditions,
|
|
3
|
+
HatchetClient,
|
|
4
|
+
} from "@hatchet-dev/typescript-sdk/v1/index.js";
|
|
5
|
+
import {
|
|
6
|
+
Or,
|
|
7
|
+
SleepCondition,
|
|
8
|
+
UserEventCondition,
|
|
9
|
+
} from "@hatchet-dev/typescript-sdk/v1/index.js";
|
|
10
|
+
import type { DurationObject, PostHogService } from "@hogsend/core";
|
|
11
|
+
import { durationToMs, evaluateEventCondition } from "@hogsend/core";
|
|
4
12
|
import type { JourneyRegistry } from "@hogsend/core/registry";
|
|
5
13
|
import {
|
|
6
14
|
isValidTimeZone,
|
|
@@ -18,11 +26,32 @@ import type {
|
|
|
18
26
|
WhenBuilder,
|
|
19
27
|
} from "@hogsend/core/types";
|
|
20
28
|
import { type Database, emailSends, journeyStates } from "@hogsend/db";
|
|
21
|
-
import
|
|
22
|
-
import { and, count, eq, max } from "drizzle-orm";
|
|
29
|
+
import { and, count, eq, max, notInArray } from "drizzle-orm";
|
|
23
30
|
import { checkEmailPreferences } from "../lib/enrollment-guards.js";
|
|
24
31
|
import { ingestEvent } from "../lib/ingestion.js";
|
|
25
32
|
import type { Logger } from "../lib/logger.js";
|
|
33
|
+
import {
|
|
34
|
+
JOURNEY_EXECUTION_TIMEOUT,
|
|
35
|
+
JOURNEY_EXECUTION_TIMEOUT_HOURS,
|
|
36
|
+
} from "./constants.js";
|
|
37
|
+
import { JourneyExitedError } from "./errors.js";
|
|
38
|
+
|
|
39
|
+
/** Journey statuses that are terminal — a journey in any of these must never be
|
|
40
|
+
* resurrected back to "active" by a wait resuming. Exported so the durable task
|
|
41
|
+
* runner can avoid clobbering a terminal row to "failed" on a cancel. */
|
|
42
|
+
export const TERMINAL_STATUSES = ["completed", "failed", "exited"] as const;
|
|
43
|
+
|
|
44
|
+
/** Upper bound for a `waitForEvent` timeout — the journey task's executionTimeout. */
|
|
45
|
+
const MAX_WAIT_MS = durationToMs({ hours: JOURNEY_EXECUTION_TIMEOUT_HOURS });
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Quote a string as a CEL single-quoted string literal, escaping backslashes
|
|
49
|
+
* then single quotes. Used to embed an externally-supplied userId into a CEL
|
|
50
|
+
* filter expression without breaking it or allowing injection.
|
|
51
|
+
*/
|
|
52
|
+
function celStringLiteral(value: string): string {
|
|
53
|
+
return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
|
54
|
+
}
|
|
26
55
|
|
|
27
56
|
interface JourneyContextConfig {
|
|
28
57
|
db: Database;
|
|
@@ -31,6 +60,12 @@ interface JourneyContextConfig {
|
|
|
31
60
|
// Hatchet's real `sleepFor` accepts a number (milliseconds) in addition to
|
|
32
61
|
// duration strings/objects; we use the number-ms form for `sleepUntil`.
|
|
33
62
|
sleepFor: (duration: DurationObject | number) => Promise<unknown>;
|
|
63
|
+
// The forwarded object is the real Hatchet `DurableContext`, which also has
|
|
64
|
+
// `waitFor` (used by `waitForEvent`). Param mirrors the SDK signature so the
|
|
65
|
+
// real context is assignable; we read back the envelope as a plain record.
|
|
66
|
+
waitFor: (
|
|
67
|
+
conditions: Conditions | Conditions[],
|
|
68
|
+
) => Promise<Record<string, unknown>>;
|
|
34
69
|
};
|
|
35
70
|
registry: JourneyRegistry;
|
|
36
71
|
logger: Logger;
|
|
@@ -114,30 +149,103 @@ export function createJourneyContext(
|
|
|
114
149
|
defaultSendWindow,
|
|
115
150
|
} = config;
|
|
116
151
|
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
152
|
+
// Enter a durable wait: flip "active" → "waiting", but ONLY if the journey
|
|
153
|
+
// hasn't already reached a terminal state (e.g. exitOn fired before we got
|
|
154
|
+
// here). A no-op update means the journey is already done — abort the run.
|
|
155
|
+
const enterWait = async (nodeId: string): Promise<void> => {
|
|
156
|
+
const entered = await db
|
|
157
|
+
.update(journeyStates)
|
|
158
|
+
.set({ status: "waiting", currentNodeId: nodeId, updatedAt: new Date() })
|
|
159
|
+
.where(
|
|
160
|
+
and(
|
|
161
|
+
eq(journeyStates.id, stateId),
|
|
162
|
+
notInArray(journeyStates.status, [...TERMINAL_STATUSES]),
|
|
163
|
+
),
|
|
164
|
+
)
|
|
165
|
+
.returning({ id: journeyStates.id });
|
|
166
|
+
|
|
167
|
+
if (entered.length === 0) {
|
|
168
|
+
throw new JourneyExitedError(stateId);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// Resume from a durable wait: flip "waiting" → "active", but ONLY if the row
|
|
173
|
+
// is still "waiting". If an exit/cancel landed during the wait the row is no
|
|
174
|
+
// longer "waiting" — abort instead of reviving a terminated journey to active
|
|
175
|
+
// (which would let a post-wait side effect fire after the journey exited).
|
|
176
|
+
const resumeFromWait = async (): Promise<void> => {
|
|
177
|
+
const resumed = await db
|
|
178
|
+
.update(journeyStates)
|
|
179
|
+
.set({ status: "active", updatedAt: new Date() })
|
|
180
|
+
.where(
|
|
181
|
+
and(eq(journeyStates.id, stateId), eq(journeyStates.status, "waiting")),
|
|
182
|
+
)
|
|
183
|
+
.returning({ id: journeyStates.id });
|
|
184
|
+
|
|
185
|
+
if (resumed.length === 0) {
|
|
186
|
+
throw new JourneyExitedError(stateId);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// Durable sleep with the guarded waiting → active lifecycle. `sleep` passes a
|
|
191
|
+
// DurationObject; `sleepUntil` passes a precomputed ms delay — Hatchet's
|
|
192
|
+
// `sleepFor` accepts both.
|
|
120
193
|
const performSleep = async (
|
|
121
194
|
durationOrMs: DurationObject | number,
|
|
122
195
|
nodeId: string,
|
|
123
196
|
): Promise<{ sleptAt: string; resumedAt: string }> => {
|
|
124
197
|
const sleptAt = new Date().toISOString();
|
|
198
|
+
await enterWait(nodeId);
|
|
199
|
+
await hatchetCtx.sleepFor(durationOrMs);
|
|
200
|
+
const resumedAt = new Date().toISOString();
|
|
201
|
+
await resumeFromWait();
|
|
202
|
+
return { sleptAt, resumedAt };
|
|
203
|
+
};
|
|
125
204
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
205
|
+
// Durably wait for THIS user's `event` OR `timeout`, whichever fires first,
|
|
206
|
+
// sharing the same guarded lifecycle as `performSleep`.
|
|
207
|
+
const performWaitForEvent = async (
|
|
208
|
+
event: string,
|
|
209
|
+
timeout: DurationObject,
|
|
210
|
+
nodeId: string,
|
|
211
|
+
): Promise<{ timedOut: boolean }> => {
|
|
212
|
+
// Reject a timeout longer than the journey task's executionTimeout up front
|
|
213
|
+
// so it fails fast at authoring time. (Eviction-capable engines may allow
|
|
214
|
+
// longer wall-clock waits, but we cap to the configured ceiling — raise
|
|
215
|
+
// JOURNEY_EXECUTION_TIMEOUT to lift it.)
|
|
216
|
+
if (durationToMs(timeout) > MAX_WAIT_MS) {
|
|
217
|
+
throw new RangeError(
|
|
218
|
+
`waitForEvent timeout exceeds the journey execution limit (${JOURNEY_EXECUTION_TIMEOUT})`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
130
221
|
|
|
131
|
-
await
|
|
222
|
+
await enterWait(nodeId);
|
|
132
223
|
|
|
133
|
-
|
|
224
|
+
// Wait for the user-scoped event or the timeout. The event branch filters on
|
|
225
|
+
// the pushed payload's top-level `userId` (see `ingestEvent`); the SDK turns
|
|
226
|
+
// the ms number into a Go duration string at serialization time.
|
|
227
|
+
const result = await hatchetCtx.waitFor(
|
|
228
|
+
Or(
|
|
229
|
+
new UserEventCondition(
|
|
230
|
+
event,
|
|
231
|
+
`input.userId == ${celStringLiteral(userId)}`,
|
|
232
|
+
"event",
|
|
233
|
+
),
|
|
234
|
+
new SleepCondition(durationToMs(timeout), "timeout"),
|
|
235
|
+
),
|
|
236
|
+
);
|
|
134
237
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
238
|
+
// Discriminate on which branch's readableDataKey ("event"/"timeout") is
|
|
239
|
+
// present. The eviction-capable path returns the `{ CREATE: { … } }`
|
|
240
|
+
// envelope; the pre-eviction path returns the inner object UN-wrapped — so
|
|
241
|
+
// strip an optional `CREATE` layer first to handle both shapes identically.
|
|
242
|
+
const fired = (("CREATE" in result ? result.CREATE : result) ??
|
|
243
|
+
{}) as Record<string, unknown>;
|
|
244
|
+
const timedOut = !("event" in fired);
|
|
139
245
|
|
|
140
|
-
|
|
246
|
+
await resumeFromWait();
|
|
247
|
+
|
|
248
|
+
return { timedOut };
|
|
141
249
|
};
|
|
142
250
|
|
|
143
251
|
return {
|
|
@@ -169,6 +277,14 @@ export function createJourneyContext(
|
|
|
169
277
|
);
|
|
170
278
|
},
|
|
171
279
|
|
|
280
|
+
async waitForEvent({ event, timeout, label }) {
|
|
281
|
+
return performWaitForEvent(
|
|
282
|
+
event,
|
|
283
|
+
timeout,
|
|
284
|
+
label ?? `wait-event:${event}`,
|
|
285
|
+
);
|
|
286
|
+
},
|
|
287
|
+
|
|
172
288
|
async checkpoint(label) {
|
|
173
289
|
await db
|
|
174
290
|
.update(journeyStates)
|
|
@@ -1,21 +1,9 @@
|
|
|
1
1
|
import type { JourneyRegistry } from "@hogsend/core/registry";
|
|
2
|
+
import { createSingleton } from "../lib/singleton.js";
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
export function setJourneyRegistry(registry: JourneyRegistry): void {
|
|
6
|
-
_registry = registry;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export function getJourneyRegistrySingleton(): JourneyRegistry {
|
|
10
|
-
if (!_registry) {
|
|
11
|
-
throw new Error(
|
|
12
|
-
"Journey registry not initialized. Call setJourneyRegistry() at startup.",
|
|
13
|
-
);
|
|
14
|
-
}
|
|
15
|
-
return _registry;
|
|
16
|
-
}
|
|
4
|
+
const singleton = createSingleton<JourneyRegistry>("Journey registry");
|
|
17
5
|
|
|
6
|
+
export const setJourneyRegistry = singleton.set;
|
|
7
|
+
export const getJourneyRegistrySingleton = singleton.get;
|
|
18
8
|
/** Reset the singleton — only for test cleanup. */
|
|
19
|
-
export
|
|
20
|
-
_registry = undefined;
|
|
21
|
-
}
|
|
9
|
+
export const resetJourneyRegistry = singleton.reset;
|
package/src/lib/alerting.ts
CHANGED
|
@@ -17,7 +17,6 @@ async function dispatchAlert(opts: {
|
|
|
17
17
|
rule: typeof alertRules.$inferSelect;
|
|
18
18
|
message: string;
|
|
19
19
|
payload: Record<string, unknown>;
|
|
20
|
-
resendApiKey?: string;
|
|
21
20
|
}): Promise<{ deliveryStatus: string; error?: string }> {
|
|
22
21
|
const config = opts.rule.channelConfig as Record<string, string>;
|
|
23
22
|
switch (opts.rule.channel) {
|
|
@@ -45,7 +44,6 @@ async function dispatchAlert(opts: {
|
|
|
45
44
|
to: config.to ?? "",
|
|
46
45
|
subject: `[Hogsend Alert] ${opts.rule.name}`,
|
|
47
46
|
body: `<p>${opts.message}</p><pre>${JSON.stringify(opts.payload, null, 2)}</pre>`,
|
|
48
|
-
resendApiKey: opts.resendApiKey ?? "",
|
|
49
47
|
});
|
|
50
48
|
return result.ok
|
|
51
49
|
? { deliveryStatus: "sent" }
|
|
@@ -62,7 +60,6 @@ async function dispatchAlert(opts: {
|
|
|
62
60
|
export async function checkAlertRules(opts: {
|
|
63
61
|
db: Database;
|
|
64
62
|
logger: Logger;
|
|
65
|
-
resendApiKey?: string;
|
|
66
63
|
}): Promise<void> {
|
|
67
64
|
const { db, logger } = opts;
|
|
68
65
|
|
|
@@ -174,7 +171,6 @@ export async function checkAlertRules(opts: {
|
|
|
174
171
|
rule,
|
|
175
172
|
message,
|
|
176
173
|
payload,
|
|
177
|
-
resendApiKey: opts.resendApiKey,
|
|
178
174
|
});
|
|
179
175
|
|
|
180
176
|
await Promise.all([
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { PostHogService } from "@hogsend/core";
|
|
2
|
+
import { createOptionalSingleton } from "./singleton.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The injected analytics service, set once by `createHogsendClient` and read by
|
|
6
|
+
* the module-level task-execution sites that have no client reference of their
|
|
7
|
+
* own (the journey durable task in `define-journey`, the bucket PostHog sync).
|
|
8
|
+
*
|
|
9
|
+
* Mirrors the journey/bucket-registry + client-schedule-defaults singletons:
|
|
10
|
+
* `createHogsendClient` runs in BOTH the API and worker processes, so by the
|
|
11
|
+
* time any worker task executes, the container has already installed the
|
|
12
|
+
* resolved `analytics` instance here — the SAME object exposed on
|
|
13
|
+
* `HogsendClient.analytics`. This makes a swapped `opts.analytics` actually
|
|
14
|
+
* load-bearing at those sites instead of falling back to the module singleton.
|
|
15
|
+
*
|
|
16
|
+
* `undefined` is a first-class value: when `POSTHOG_API_KEY` is unset the
|
|
17
|
+
* container resolves `analytics` to `undefined` and installs it here, so every
|
|
18
|
+
* read remains a no-op exactly as before — hence the optional singleton variant.
|
|
19
|
+
*/
|
|
20
|
+
const singleton = createOptionalSingleton<PostHogService>();
|
|
21
|
+
|
|
22
|
+
export const setAnalytics = singleton.set;
|
|
23
|
+
export const getAnalytics = singleton.get;
|
|
24
|
+
/** Reset the singleton — only for test cleanup. */
|
|
25
|
+
export const resetAnalytics = singleton.reset;
|