@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 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.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.3.0",
39
- "@hogsend/email": "^0.3.0",
40
- "@hogsend/db": "^0.3.0",
41
- "@hogsend/plugin-posthog": "^0.3.0",
42
- "@hogsend/plugin-resend": "^0.3.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.info(
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
- let _registry: BucketRegistry | undefined;
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 function resetBucketRegistry(): void {
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
- logger.info(`Journey registry loaded: ${registry.count()} journeys`);
255
- logger.info(`Bucket registry loaded: ${bucketRegistry.count()} buckets`);
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 { 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();
@@ -129,7 +131,9 @@ export function defineJourney(options: {
129
131
  journeyName: meta.name,
130
132
  };
131
133
 
132
- const posthog = getPostHog();
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
- await db
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(eq(journeyStates.id, stateId));
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 { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
2
- import type { DurationObject } from "@hogsend/core";
3
- import { evaluateEventCondition } from "@hogsend/core";
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 type { PostHogService } from "@hogsend/plugin-posthog";
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
- // Shared wait lifecycle: mark the state "waiting", durably sleep, mark it
118
- // "active" again. `sleep` passes a DurationObject; `sleepUntil` passes a
119
- // precomputed ms delayHatchet's `sleepFor` accepts both.
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
- await db
127
- .update(journeyStates)
128
- .set({ status: "waiting", currentNodeId: nodeId, updatedAt: new Date() })
129
- .where(eq(journeyStates.id, stateId));
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 hatchetCtx.sleepFor(durationOrMs);
222
+ await enterWait(nodeId);
132
223
 
133
- const resumedAt = new Date().toISOString();
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
- await db
136
- .update(journeyStates)
137
- .set({ status: "active", updatedAt: new Date() })
138
- .where(eq(journeyStates.id, stateId));
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
- return { sleptAt, resumedAt };
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
- let _registry: JourneyRegistry | undefined;
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 function resetJourneyRegistry(): void {
20
- _registry = undefined;
21
- }
9
+ export const resetJourneyRegistry = singleton.reset;
@@ -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;