@hogsend/engine 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import { BucketRegistry } from "@hogsend/core/registry";
2
+ import type { DefinedJourney } from "../journeys/define-journey.js";
2
3
  import { parseEnabledFilter } from "../journeys/registry.js";
3
4
  import { bucketExpiryTask } from "../workflows/bucket-reconcile.js";
4
5
  import type { DefinedBucket } from "./define-bucket.js";
@@ -60,3 +61,83 @@ export function selectBucketTasks(
60
61
  // the DefinedBucket task shape — both are `hatchet.durableTask` returns.
61
62
  return [bucketExpiryTask as NonNullable<DefinedBucket["task"]>];
62
63
  }
64
+
65
+ /**
66
+ * Select the Hatchet durable tasks for the reaction journeys generated by
67
+ * `bucket.on()` across the ENABLED buckets (Section 9). Reactions are
68
+ * bucket-owned, so they are gated by `ENABLED_BUCKETS` — NOT `ENABLED_JOURNEYS`
69
+ * (their `bucket-<id>-on-<kind>` ids never appear in a consumer's
70
+ * `ENABLED_JOURNEYS` csv, so folding them into the journeys[] array would drop
71
+ * every reaction whenever ENABLED_JOURNEYS is a csv).
72
+ *
73
+ * Asserts no reaction-id collision (a build-time loud failure) before returning.
74
+ */
75
+ export function selectBucketReactionTasks(
76
+ buckets: DefinedBucket[],
77
+ enabledFilter?: string,
78
+ userJourneyIds?: Iterable<string>,
79
+ ): NonNullable<DefinedBucket["task"]>[] {
80
+ const enabled = parseEnabledFilter(enabledFilter);
81
+ const enabledBuckets = buckets.filter(
82
+ (b) => enabled === "*" || enabled.has(b.meta.id),
83
+ );
84
+ // Loud boot failure on a duplicate reaction id / user-journey collision
85
+ // instead of a silent register-last-wins drop (Section 9). Pass the consumer
86
+ // journey ids so a reaction id colliding with a hand-written journey throws.
87
+ assertNoReactionIdCollisions(enabledBuckets, userJourneyIds);
88
+ return enabledBuckets.flatMap((b) =>
89
+ b.reactions.map((r) => r.task),
90
+ ) as NonNullable<DefinedBucket["task"]>[];
91
+ }
92
+
93
+ /**
94
+ * Collect the reaction `DefinedJourney`s across the ENABLED buckets (Section 9).
95
+ * Bucket-gated by `ENABLED_BUCKETS` for the same reason as
96
+ * {@link selectBucketReactionTasks}. The container registers their metas into the
97
+ * journey registry directly (bypassing the ENABLED_JOURNEYS filter) so the admin
98
+ * `feedsJourneys` and the dwell cron can resolve them.
99
+ */
100
+ export function collectBucketReactionJourneys(
101
+ buckets: DefinedBucket[],
102
+ enabledFilter?: string,
103
+ ): DefinedJourney[] {
104
+ const enabled = parseEnabledFilter(enabledFilter);
105
+ return buckets
106
+ .filter((b) => enabled === "*" || enabled.has(b.meta.id))
107
+ .flatMap((b) => b.reactions);
108
+ }
109
+
110
+ /**
111
+ * Reaction tasks register on Hatchet under `journey-${meta.id}`. Two buckets
112
+ * sharing an id, two reactions colliding on a generated id, or a user journey
113
+ * named `bucket-<id>-on-<kind>` collide silently (register-last-wins) or throw on
114
+ * boot. Throw a descriptive build-time error instead (Section 9).
115
+ *
116
+ * @param enabledBuckets the already-enabled buckets to scan.
117
+ * @param userJourneyIds ids of the consumer's hand-written journeys (optional);
118
+ * a reaction id colliding with one of these throws.
119
+ */
120
+ export function assertNoReactionIdCollisions(
121
+ enabledBuckets: DefinedBucket[],
122
+ userJourneyIds?: Iterable<string>,
123
+ ): void {
124
+ const seen = new Set<string>();
125
+ const userIds = new Set(userJourneyIds ?? []);
126
+
127
+ for (const bucket of enabledBuckets) {
128
+ for (const reaction of bucket.reactions) {
129
+ const id = reaction.meta.id;
130
+ if (seen.has(id)) {
131
+ throw new Error(
132
+ `Bucket reaction id collision: "${id}" is generated by more than one reaction (check for duplicate bucket ids or two reactions of the same kind on bucket "${bucket.meta.id}").`,
133
+ );
134
+ }
135
+ if (userIds.has(id)) {
136
+ throw new Error(
137
+ `Bucket reaction id collision: generated reaction "${id}" (bucket "${bucket.meta.id}") collides with a user journey of the same id.`,
138
+ );
139
+ }
140
+ seen.add(id);
141
+ }
142
+ }
143
+ }
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,19 +9,22 @@ 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";
17
+ import { createBucketAccessor } from "./buckets/bucket-access.js";
19
18
  import type { DefinedBucket } from "./buckets/define-bucket.js";
20
- import { buildBucketRegistry } from "./buckets/registry.js";
19
+ import {
20
+ buildBucketRegistry,
21
+ collectBucketReactionJourneys,
22
+ } from "./buckets/registry.js";
21
23
  import { env } from "./env.js";
22
24
  import { setClientScheduleDefaults } from "./journeys/client-defaults-singleton.js";
23
25
  import type { DefinedJourney } from "./journeys/define-journey.js";
24
26
  import { buildJourneyRegistry } from "./journeys/registry.js";
27
+ import { setAnalytics } from "./lib/analytics-singleton.js";
25
28
  import { type Auth, createAuth } from "./lib/auth.js";
26
29
  import { setEmailService } from "./lib/email.js";
27
30
  import type {
@@ -201,10 +204,38 @@ export function createHogsendClient(
201
204
  // Installs the bucket registry singleton in BOTH the API and worker processes
202
205
  // (both call createHogsendClient); the real-time ingest path reads it via
203
206
  // getBucketRegistrySingleton().
204
- const bucketRegistry = buildBucketRegistry(
205
- opts.buckets ?? [],
206
- opts.enabledBuckets ?? env.ENABLED_BUCKETS,
207
- );
207
+ const buckets = opts.buckets ?? [];
208
+ const enabledBuckets = opts.enabledBuckets ?? env.ENABLED_BUCKETS;
209
+ const bucketRegistry = buildBucketRegistry(buckets, enabledBuckets);
210
+
211
+ // Register the reaction journeys generated by `bucket.on()` into the journey
212
+ // registry AFTER buildJourneyRegistry, bypassing the ENABLED_JOURNEYS filter:
213
+ // reactions are bucket-owned and were already gated by ENABLED_BUCKETS
214
+ // (collectBucketReactionJourneys), so their `bucket-<id>-on-<kind>` ids must NOT
215
+ // be subject to the journeys csv (Section 9). Both API and worker call
216
+ // createHogsendClient, so the singleton carries reaction metas in both
217
+ // processes (needed for admin feedsJourneys + the dwell-cron lookup).
218
+ for (const reaction of collectBucketReactionJourneys(
219
+ buckets,
220
+ enabledBuckets,
221
+ )) {
222
+ registry.register(reaction.meta);
223
+ }
224
+
225
+ // Re-bind the member-access accessors on each enabled bucket to THIS
226
+ // container's db so `overrides.db` flows through (the accessors default to the
227
+ // getDb() singleton at defineBucket time, before any container exists —
228
+ // bucket-access.ts dbResolver seam). The enabled set mirrors
229
+ // buildBucketRegistry's filter.
230
+ const enabledIds = new Set(bucketRegistry.getAll().map((b) => b.id));
231
+ for (const bucket of buckets) {
232
+ if (!enabledIds.has(bucket.meta.id)) continue;
233
+ const accessor = createBucketAccessor(bucket.meta.id, () => db);
234
+ bucket.count = accessor.count;
235
+ bucket.has = accessor.has;
236
+ bucket.members = accessor.members;
237
+ bucket.membersIterator = accessor.membersIterator;
238
+ }
208
239
 
209
240
  const provider =
210
241
  opts.email?.provider ??
@@ -251,8 +282,17 @@ export function createHogsendClient(
251
282
 
252
283
  const analytics = opts.analytics ?? getPostHog();
253
284
 
254
- logger.info(`Journey registry loaded: ${registry.count()} journeys`);
255
- logger.info(`Bucket registry loaded: ${bucketRegistry.count()} buckets`);
285
+ // Expose the resolved analytics instance to the module-level task-execution
286
+ // sites that have no client reference (the journey durable task in
287
+ // define-journey, the bucket PostHog sync). `createHogsendClient` runs in both
288
+ // the API and worker, so this is installed before any worker task runs. May be
289
+ // undefined (no POSTHOG_API_KEY) — the reads stay no-ops.
290
+ setAnalytics(analytics);
291
+
292
+ // Counts are surfaced by the boot banner / structured ready log (lib/boot.ts);
293
+ // keep these at debug for non-boot contexts (tests, REPL, library use).
294
+ logger.debug(`Journey registry loaded: ${registry.count()} journeys`);
295
+ logger.debug(`Bucket registry loaded: ${bucketRegistry.count()} buckets`);
256
296
 
257
297
  return {
258
298
  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";
@@ -23,6 +39,18 @@ export {
23
39
  // --- App / container / worker factories ---
24
40
  export { type AppEnv, type CreateAppOptions, createApp } from "./app.js";
25
41
  // --- Buckets ---
42
+ export {
43
+ type BucketAccessor,
44
+ type BucketMemberRow,
45
+ createBucketAccessor,
46
+ type MembersResult,
47
+ } from "./buckets/bucket-access.js";
48
+ export type {
49
+ BucketLeaveReason,
50
+ DwellOptions,
51
+ EnterOptions,
52
+ LeaveOptions,
53
+ } from "./buckets/bucket-reactions.js";
26
54
  export {
27
55
  type BucketTransition,
28
56
  type BucketTransitionKind,
@@ -34,6 +62,8 @@ export {
34
62
  } from "./buckets/define-bucket.js";
35
63
  export {
36
64
  buildBucketRegistry,
65
+ collectBucketReactionJourneys,
66
+ selectBucketReactionTasks,
37
67
  selectBucketTasks,
38
68
  } from "./buckets/registry.js";
39
69
  export {
@@ -73,6 +103,14 @@ export {
73
103
  type BatchedBackfillResult,
74
104
  runBatchedBackfill,
75
105
  } from "./lib/backfill.js";
106
+ // --- Boot output (engine-owned startup banner / structured ready log) ---
107
+ export {
108
+ type ApiReadyInfo,
109
+ getEngineVersion,
110
+ reportApiReady,
111
+ reportWorkerReady,
112
+ type WorkerReadyInfo,
113
+ } from "./lib/boot.js";
76
114
  // --- Bucket transition emission (shared by real-time / cron / fast-expiry) ---
77
115
  export {
78
116
  type BucketTransitionSource,
@@ -7,6 +7,7 @@ import type {
7
7
  } from "@hogsend/core/types";
8
8
  import { contacts, journeyConfigs, journeyStates } from "@hogsend/db";
9
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,7 +15,6 @@ 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
20
  import { JOURNEY_EXECUTION_TIMEOUT } from "./constants.js";
@@ -131,7 +131,9 @@ export function defineJourney(options: {
131
131
  journeyName: meta.name,
132
132
  };
133
133
 
134
- 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();
135
137
  const scheduleDefaults = getClientScheduleDefaults();
136
138
 
137
139
  // Resolve the user's timezone via the precedence chain (explicit is N/A
@@ -7,7 +7,7 @@ import {
7
7
  SleepCondition,
8
8
  UserEventCondition,
9
9
  } from "@hatchet-dev/typescript-sdk/v1/index.js";
10
- import type { DurationObject } from "@hogsend/core";
10
+ import type { DurationObject, PostHogService } from "@hogsend/core";
11
11
  import { durationToMs, evaluateEventCondition } from "@hogsend/core";
12
12
  import type { JourneyRegistry } from "@hogsend/core/registry";
13
13
  import {
@@ -26,7 +26,6 @@ import type {
26
26
  WhenBuilder,
27
27
  } from "@hogsend/core/types";
28
28
  import { type Database, emailSends, journeyStates } from "@hogsend/db";
29
- import type { PostHogService } from "@hogsend/plugin-posthog";
30
29
  import { and, count, eq, max, notInArray } from "drizzle-orm";
31
30
  import { checkEmailPreferences } from "../lib/enrollment-guards.js";
32
31
  import { ingestEvent } from "../lib/ingestion.js";
@@ -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;
@@ -0,0 +1,176 @@
1
+ import { createRequire } from "node:module";
2
+ import color from "picocolors";
3
+ import type { HogsendClient } from "../container.js";
4
+ import { API_VERSION } from "../env.js";
5
+
6
+ /**
7
+ * Engine-owned boot output. ONE place renders the "we're up" message for the
8
+ * API and the worker, so every scaffolded `create-hogsend` app gets the same
9
+ * polished startup for free — the entry points just call these.
10
+ *
11
+ * Two modes, picked from the environment (never a flag):
12
+ * - banner: a branded, minimal box (the `create-hogsend` look — magenta badge,
13
+ * ✓ checks, cyan links) printed straight to stdout. Only when stdout is a TTY
14
+ * AND `NODE_ENV === "development"`, i.e. an interactive `pnpm dev`.
15
+ * - structured: a single `logger.info("… ready", { … })` line. Everywhere else
16
+ * (production, CI, piped output, tests) so log scraping stays intact.
17
+ *
18
+ * The scattered `registry loaded` / `studio mounted` / `server running` lines
19
+ * are demoted to `debug`; this banner is the single source of truth on boot.
20
+ */
21
+
22
+ // Last-resort value when the runtime manifest read below fails (e.g. a pruned
23
+ // node_modules behind a bundler). The read is authoritative in every normal dev
24
+ // run and deploy, so this is effectively never hit — `"unknown"` keeps it honest
25
+ // rather than risking a stale hard-coded version slipping into structured logs.
26
+ const FALLBACK_ENGINE_VERSION = "unknown";
27
+
28
+ // Conventional Vite dev-server origin for the Studio package (`pnpm dev` starts
29
+ // it). In production the Studio is served by the API at `${API_PUBLIC_URL}/studio`.
30
+ const STUDIO_DEV_URL = "http://localhost:5173";
31
+ const DOCS_URL = "https://docs.hogsend.com";
32
+
33
+ let cachedEngineVersion: string | undefined;
34
+
35
+ /** The running `@hogsend/engine` package version (e.g. "0.4.0"). */
36
+ export function getEngineVersion(): string {
37
+ if (cachedEngineVersion) return cachedEngineVersion;
38
+ try {
39
+ // Resolve the engine's own manifest from the consumer's module graph —
40
+ // correct under tsx (workspace symlink) and a bundled deploy alike. Needs
41
+ // `"./package.json"` in this package's `exports`.
42
+ const require = createRequire(import.meta.url);
43
+ const pkg = require("@hogsend/engine/package.json") as { version?: string };
44
+ cachedEngineVersion = pkg.version ?? FALLBACK_ENGINE_VERSION;
45
+ } catch {
46
+ cachedEngineVersion = FALLBACK_ENGINE_VERSION;
47
+ }
48
+ return cachedEngineVersion;
49
+ }
50
+
51
+ const BADGE = color.bgMagenta(color.black(" hogsend "));
52
+
53
+ /** Interactive `pnpm dev` in a real terminal — the only place the banner shows. */
54
+ function bannerMode(client: HogsendClient): boolean {
55
+ return Boolean(process.stdout.isTTY) && client.env.NODE_ENV === "development";
56
+ }
57
+
58
+ function plural(n: number, word: string): string {
59
+ return `${n} ${word}${n === 1 ? "" : "s"}`;
60
+ }
61
+
62
+ function writeBanner(lines: (string | null)[]): void {
63
+ const body = lines.filter((l): l is string => l !== null).join("\n");
64
+ process.stdout.write(`\n${body}\n\n`);
65
+ }
66
+
67
+ export interface ApiReadyInfo {
68
+ client: HogsendClient;
69
+ /** The port the HTTP server bound to. */
70
+ port: number;
71
+ /** Applied engine-track schema version, when the boot guard ran. */
72
+ schemaVersion?: string | null;
73
+ }
74
+
75
+ /** Render the API "ready" output (banner in dev TTY, structured log otherwise). */
76
+ export function reportApiReady(info: ApiReadyInfo): void {
77
+ const { client, port } = info;
78
+ const engineVersion = getEngineVersion();
79
+ const journeys = client.registry.count();
80
+ const buckets = client.bucketRegistry.count();
81
+ const templates = Object.keys(client.templates).length;
82
+ const localUrl = `http://localhost:${port}`;
83
+
84
+ if (!bannerMode(client)) {
85
+ client.logger.info("Hogsend API ready", {
86
+ engineVersion,
87
+ apiVersion: API_VERSION,
88
+ port,
89
+ url: client.env.API_PUBLIC_URL,
90
+ journeys,
91
+ buckets,
92
+ templates,
93
+ schema: info.schemaVersion ?? undefined,
94
+ });
95
+ return;
96
+ }
97
+
98
+ const dim = color.dim;
99
+ const ok = color.green("✓");
100
+ const loaded = [
101
+ plural(journeys, "journey"),
102
+ plural(buckets, "bucket"),
103
+ plural(templates, "template"),
104
+ ].join(dim(" · "));
105
+ const label = (text: string) => dim(text.padEnd(7));
106
+
107
+ writeBanner([
108
+ `${BADGE} ${dim(`engine ${engineVersion} · api ${API_VERSION}`)}`,
109
+ "",
110
+ ` ${ok} ${loaded}`,
111
+ info.schemaVersion
112
+ ? ` ${ok} schema in sync ${dim(`(${info.schemaVersion})`)}`
113
+ : null,
114
+ "",
115
+ ` ${label("API")}${color.cyan(localUrl)}`,
116
+ ` ${label("Docs")}${color.cyan(`${localUrl}/docs`)}`,
117
+ ` ${label("Studio")}${color.cyan(STUDIO_DEV_URL)}`,
118
+ ` ${label("Guides")}${color.cyan(DOCS_URL)}`,
119
+ "",
120
+ ` ${dim("Next")} fire a test event in ${color.cyan("Studio › Debug")} ${dim("·")} run the worker: ${color.cyan("pnpm worker:dev")}`,
121
+ ]);
122
+ }
123
+
124
+ export interface WorkerReadyInfo {
125
+ client: HogsendClient;
126
+ journeyTasks: number;
127
+ bucketTasks: number;
128
+ /** Reaction journey tasks generated by `bucket.on()` (Section 9). */
129
+ bucketReactionTasks: number;
130
+ builtinTasks: number;
131
+ }
132
+
133
+ /** Render the worker "ready" output (banner in dev TTY, structured log otherwise). */
134
+ export function reportWorkerReady(info: WorkerReadyInfo): void {
135
+ const {
136
+ client,
137
+ journeyTasks,
138
+ bucketTasks,
139
+ bucketReactionTasks,
140
+ builtinTasks,
141
+ } = info;
142
+ const engineVersion = getEngineVersion();
143
+ const hatchetHost = client.env.HATCHET_CLIENT_HOST_PORT;
144
+
145
+ if (!bannerMode(client)) {
146
+ client.logger.info("Hogsend worker ready", {
147
+ engineVersion,
148
+ hatchet: hatchetHost,
149
+ namespace: client.env.HATCHET_CLIENT_NAMESPACE || undefined,
150
+ journeyTasks,
151
+ bucketTasks,
152
+ bucketReactionTasks,
153
+ builtinTasks,
154
+ });
155
+ return;
156
+ }
157
+
158
+ const dim = color.dim;
159
+ const ok = color.green("✓");
160
+ const tasks = [
161
+ plural(journeyTasks, "journey task"),
162
+ plural(bucketTasks, "bucket task"),
163
+ plural(bucketReactionTasks, "reaction task"),
164
+ plural(builtinTasks, "built-in task"),
165
+ ].join(dim(" · "));
166
+
167
+ writeBanner([
168
+ `${BADGE} ${dim(`worker · engine ${engineVersion}`)}`,
169
+ "",
170
+ ` ${ok} registered on Hatchet ${dim(`(${hatchetHost})`)}`,
171
+ ` ${ok} ${tasks}`,
172
+ "",
173
+ ` ${dim("Listening — journeys fire as events arrive.")}`,
174
+ ` ${dim("Send one:")} ${color.cyan("POST /v1/ingest")} ${dim("· or Studio › Debug")}`,
175
+ ]);
176
+ }
@@ -2,11 +2,12 @@ import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
2
2
  import type { BucketMeta } from "@hogsend/core";
3
3
  import type { JourneyRegistry } from "@hogsend/core/registry";
4
4
  import type { Database } from "@hogsend/db";
5
+ import type { BucketLeaveReason } from "../buckets/bucket-reactions.js";
5
6
  import { syncBucketToPostHog } from "./bucket-posthog-sync.js";
6
7
  import { ingestEvent } from "./ingestion.js";
7
8
  import type { Logger } from "./logger.js";
8
9
 
9
- export type BucketTransitionKind = "entered" | "left";
10
+ export type BucketTransitionKind = "entered" | "left" | "dwell";
10
11
 
11
12
  /** Where a transition originated — carried on the emitted event properties. */
12
13
  export type BucketTransitionSource =
@@ -40,6 +41,16 @@ export async function emitBucketTransition(opts: {
40
41
  userEmail: string | null;
41
42
  epoch: number;
42
43
  source?: BucketTransitionSource;
44
+ /** Carried on a `left` transition's properties → `ctx.reason`. */
45
+ reason?: BucketLeaveReason;
46
+ /** The dwell schedule label (`after-<ms>`/`every-<ms>`) — `dwell` only. */
47
+ dwellLabel?: string;
48
+ /**
49
+ * The deterministic dwell interval ordinal — `dwell` only. Rides the
50
+ * idempotencyKey so a same-sweep retry recomputes the identical key and is
51
+ * absorbed by the `userEvents` dedup. Surfaced as `dwellCount`.
52
+ */
53
+ dwellOrdinal?: number;
43
54
  }): Promise<void> {
44
55
  const {
45
56
  db,
@@ -52,22 +63,53 @@ export async function emitBucketTransition(opts: {
52
63
  userEmail,
53
64
  epoch,
54
65
  source = "event",
66
+ reason,
67
+ dwellLabel,
68
+ dwellOrdinal,
55
69
  } = opts;
56
70
 
71
+ // The dwell transition emits a labelled event so two dwell reactions on one
72
+ // bucket (one `after`, one `every`) route distinctly; enter/left keep the
73
+ // canonical `bucket:<kind>:<id>` form. The idempotencyKey is recomputed
74
+ // identically by a retry: enter/left key on the membership epoch, dwell keys
75
+ // on the (label, ordinal) so a same-sweep retry rides the userEvents dedup.
76
+ const eventName =
77
+ kind === "dwell"
78
+ ? `bucket:dwell:${bucket.id}:${dwellLabel}`
79
+ : `bucket:${kind}:${bucket.id}`;
80
+ const idempotencyKey =
81
+ kind === "dwell"
82
+ ? `bucket:${bucket.id}:${userId}:dwell:${dwellLabel}:${dwellOrdinal}`
83
+ : `bucket:${bucket.id}:${userId}:${kind}:${epoch}`;
84
+
57
85
  const properties: Record<string, unknown> = {
58
86
  bucketId: bucket.id,
59
87
  bucketName: bucket.name,
60
88
  userId,
61
89
  transition: kind,
62
90
  source,
91
+ // entryCount is always carried (the membership ordinal); the reaction `run`
92
+ // derives `isFirstEntry` from it.
93
+ entryCount: epoch,
63
94
  };
95
+ // reason is carried on a leave so the `leave` reaction can filter on it.
96
+ if (kind === "left" && reason != null) {
97
+ properties.reason = reason;
98
+ }
99
+ // dwellCount = the interval ordinal, surfaced to the dwell reaction.
100
+ if (kind === "dwell" && dwellOrdinal != null) {
101
+ properties.dwellCount = dwellOrdinal;
102
+ }
64
103
 
65
104
  // Optional PostHog person-property mirror (Section 12). Off by default; a
66
105
  // no-op without POSTHOG_API_KEY. Wired here, the single transition path shared
67
106
  // by all three producers (real-time / reconcile / fast-expiry), so the sync
68
107
  // fires exactly once per emitted transition. Best-effort — it never blocks the
69
- // event emit below.
70
- syncBucketToPostHog({ logger, kind, bucket, userId });
108
+ // event emit below. Dwell is a recurring membership-age tick, not a state
109
+ // change, so it does NOT mirror a person property.
110
+ if (kind === "entered" || kind === "left") {
111
+ syncBucketToPostHog({ logger, kind, bucket, userId });
112
+ }
71
113
 
72
114
  // Per-bucket alias — the recommended, narrowly-routed binding. The
73
115
  // deterministic idempotencyKey rides the userEvents dedup short-circuit as
@@ -78,11 +120,11 @@ export async function emitBucketTransition(opts: {
78
120
  hatchet,
79
121
  logger,
80
122
  event: {
81
- event: `bucket:${kind}:${bucket.id}`,
123
+ event: eventName,
82
124
  userId,
83
125
  userEmail: userEmail ?? "",
84
126
  properties,
85
- idempotencyKey: `bucket:${bucket.id}:${userId}:${kind}:${epoch}`,
127
+ idempotencyKey,
86
128
  },
87
129
  });
88
130