@hogsend/engine 0.1.1 → 0.2.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/src/container.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
2
2
  import type { TimeZone } from "@hogsend/core";
3
- import type { JourneyRegistry } from "@hogsend/core/registry";
3
+ import type { BucketRegistry, JourneyRegistry } from "@hogsend/core/registry";
4
4
  import type { SendWindow } from "@hogsend/core/schedule";
5
5
  import {
6
6
  createDatabase,
@@ -16,6 +16,8 @@ import {
16
16
  type EmailProvider,
17
17
  } from "@hogsend/plugin-resend";
18
18
  import type { Resend } from "resend";
19
+ import type { DefinedBucket } from "./buckets/define-bucket.js";
20
+ import { buildBucketRegistry } from "./buckets/registry.js";
19
21
  import { env } from "./env.js";
20
22
  import { setClientScheduleDefaults } from "./journeys/client-defaults-singleton.js";
21
23
  import type { DefinedJourney } from "./journeys/define-journey.js";
@@ -58,6 +60,13 @@ export interface HogsendClient {
58
60
  templates: TemplateRegistry;
59
61
  analytics?: PostHogService;
60
62
  registry: JourneyRegistry;
63
+ /**
64
+ * The bucket registry (id map + event/property inverted indexes for candidate
65
+ * narrowing). Built and installed as the process singleton at client build;
66
+ * the real-time ingest path reads it via `getBucketRegistrySingleton()`.
67
+ * Empty when no buckets are wired.
68
+ */
69
+ bucketRegistry: BucketRegistry;
61
70
  hatchet: HatchetClient;
62
71
  /**
63
72
  * The client repo's migration journal (`migrations/meta/_journal.json`),
@@ -77,6 +86,8 @@ export interface HogsendClient {
77
86
  export interface HogsendClientOptions {
78
87
  /** Journeys to register in the {@link JourneyRegistry}. Defaults to none. */
79
88
  journeys?: DefinedJourney[];
89
+ /** Buckets to register in the {@link BucketRegistry}. Defaults to none. */
90
+ buckets?: DefinedBucket[];
80
91
  /**
81
92
  * Email is a first-class channel. Its config is grouped here rather than
82
93
  * spread across top-level args — the engine owns the cohesive email pipeline
@@ -112,6 +123,11 @@ export interface HogsendClientOptions {
112
123
  * `env.ENABLED_JOURNEYS`.
113
124
  */
114
125
  enabledJourneys?: string;
126
+ /**
127
+ * Comma-separated ids (or `*`) controlling which buckets load. Defaults to
128
+ * `env.ENABLED_BUCKETS`.
129
+ */
130
+ enabledBuckets?: string;
115
131
  /**
116
132
  * The client repo's migration journal for the `schema.client` health block.
117
133
  * Defaults to `{ entries: [] }` (empty client track ⇒ trivially in sync).
@@ -182,6 +198,14 @@ export function createHogsendClient(
182
198
  opts.enabledJourneys ?? env.ENABLED_JOURNEYS,
183
199
  );
184
200
 
201
+ // Installs the bucket registry singleton in BOTH the API and worker processes
202
+ // (both call createHogsendClient); the real-time ingest path reads it via
203
+ // getBucketRegistrySingleton().
204
+ const bucketRegistry = buildBucketRegistry(
205
+ opts.buckets ?? [],
206
+ opts.enabledBuckets ?? env.ENABLED_BUCKETS,
207
+ );
208
+
185
209
  const provider =
186
210
  opts.email?.provider ??
187
211
  createResendProvider({
@@ -228,6 +252,7 @@ export function createHogsendClient(
228
252
  const analytics = opts.analytics ?? getPostHog();
229
253
 
230
254
  logger.info(`Journey registry loaded: ${registry.count()} journeys`);
255
+ logger.info(`Bucket registry loaded: ${bucketRegistry.count()} buckets`);
231
256
 
232
257
  return {
233
258
  env,
@@ -240,6 +265,7 @@ export function createHogsendClient(
240
265
  templates,
241
266
  analytics,
242
267
  registry,
268
+ bucketRegistry,
243
269
  hatchet: opts.overrides?.hatchet ?? hatchet,
244
270
  clientJournal: opts.clientJournal ?? { entries: [] },
245
271
  defaults,
package/src/env.ts CHANGED
@@ -54,6 +54,12 @@ export const env = createEnv({
54
54
  ADMIN_API_KEY: z.string().min(1).optional(),
55
55
  API_PUBLIC_URL: z.string().url().default("http://localhost:3002"),
56
56
  ENABLED_JOURNEYS: z.string().default("*"),
57
+ // Buckets: same `"*"`-or-csv contract as ENABLED_JOURNEYS (Section 9.3).
58
+ // Evaluated at worker boot — a toggle requires a worker restart; only the
59
+ // bucket_configs DB override is hot.
60
+ ENABLED_BUCKETS: z.string().default("*"),
61
+ // Cadence for the engine-owned bucket reconcile cron (time-based leaves).
62
+ BUCKET_RECONCILE_CRON: z.string().default("*/5 * * * *"),
57
63
  },
58
64
  runtimeEnv: process.env,
59
65
  emptyStringAsUndefined: true,
package/src/index.ts CHANGED
@@ -6,7 +6,10 @@
6
6
  // Core helpers used by content journeys (days/hours/minutes, condition + journey
7
7
  // types) so content can import everything from `@hogsend/engine`.
8
8
  export * from "@hogsend/core";
9
- export { JourneyRegistry } from "@hogsend/core/registry";
9
+ export {
10
+ BucketRegistry,
11
+ JourneyRegistry,
12
+ } from "@hogsend/core/registry";
10
13
  // --- Re-exports for content ---
11
14
  // Schema/version helpers used by the boot guard and the /v1/health route.
12
15
  export {
@@ -19,6 +22,25 @@ export {
19
22
  } from "@hogsend/db";
20
23
  // --- App / container / worker factories ---
21
24
  export { type AppEnv, type CreateAppOptions, createApp } from "./app.js";
25
+ // --- Buckets ---
26
+ export {
27
+ type BucketTransition,
28
+ type BucketTransitionKind,
29
+ checkBucketMembership,
30
+ } from "./buckets/check-membership.js";
31
+ export {
32
+ type DefinedBucket,
33
+ defineBucket,
34
+ } from "./buckets/define-bucket.js";
35
+ export {
36
+ buildBucketRegistry,
37
+ selectBucketTasks,
38
+ } from "./buckets/registry.js";
39
+ export {
40
+ getBucketRegistrySingleton,
41
+ resetBucketRegistry,
42
+ setBucketRegistry,
43
+ } from "./buckets/registry-singleton.js";
22
44
  export {
23
45
  createHogsendClient,
24
46
  type HogsendClient,
@@ -50,6 +72,11 @@ export {
50
72
  type BatchedBackfillResult,
51
73
  runBatchedBackfill,
52
74
  } from "./lib/backfill.js";
75
+ // --- Bucket transition emission (shared by real-time / cron / fast-expiry) ---
76
+ export {
77
+ type BucketTransitionSource,
78
+ emitBucketTransition,
79
+ } from "./lib/bucket-emit.js";
53
80
  // --- Infrastructure singletons ---
54
81
  export { getDb } from "./lib/db.js";
55
82
  // --- Email ---
@@ -121,6 +148,17 @@ export {
121
148
  createWorker,
122
149
  type Worker,
123
150
  } from "./worker.js";
151
+ export {
152
+ type BucketBackfillInput,
153
+ bucketBackfillTask,
154
+ computeCriteriaHash,
155
+ enqueueBucketBackfills,
156
+ } from "./workflows/bucket-backfill.js";
157
+ export {
158
+ type BucketArmExpiryInput,
159
+ bucketExpiryTask,
160
+ bucketReconcileTask,
161
+ } from "./workflows/bucket-reconcile.js";
124
162
  export { checkAlertsTask } from "./workflows/check-alerts.js";
125
163
  export { importContactsTask } from "./workflows/import-contacts.js";
126
164
  // --- Built-in Hatchet workflow tasks ---
@@ -0,0 +1,107 @@
1
+ import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
2
+ import type { BucketMeta } from "@hogsend/core";
3
+ import type { JourneyRegistry } from "@hogsend/core/registry";
4
+ import type { Database } from "@hogsend/db";
5
+ import { syncBucketToPostHog } from "./bucket-posthog-sync.js";
6
+ import { ingestEvent } from "./ingestion.js";
7
+ import type { Logger } from "./logger.js";
8
+
9
+ export type BucketTransitionKind = "entered" | "left";
10
+
11
+ /** Where a transition originated — carried on the emitted event properties. */
12
+ export type BucketTransitionSource =
13
+ | "event"
14
+ | "reconcile"
15
+ | "backfill"
16
+ | "manual";
17
+
18
+ /**
19
+ * Emit a bucket transition back through `ingestEvent` (the `ctx.trigger`
20
+ * precedent) — shared by ALL three producers (real-time `checkBucketMembership`,
21
+ * the reconcile cron, and the fast-expiry timer) so they compute byte-identical
22
+ * `idempotencyKey`s for the same transition and converge to ONE emission
23
+ * (Section 6.3 worked example).
24
+ *
25
+ * Persists to `userEvents`, pushes to Hatchet (routing to journeys), and runs
26
+ * `checkExits`. Emits the per-bucket ALIAS (`bucket:<kind>:<id>`) by default; the
27
+ * generic `bucket:<kind>` is emitted ONLY when a generic-bound journey actually
28
+ * exists (aliased-only default — Section 8.5). `epoch` is the winning membership
29
+ * row's `entryCount`, read off the single winning mutation by the caller.
30
+ */
31
+ export async function emitBucketTransition(opts: {
32
+ db: Database;
33
+ /** The JOURNEY registry — forwarded into the recursive emit ingestEvent. */
34
+ registry: JourneyRegistry;
35
+ hatchet: HatchetClient;
36
+ logger: Logger;
37
+ kind: BucketTransitionKind;
38
+ bucket: BucketMeta;
39
+ userId: string;
40
+ userEmail: string | null;
41
+ epoch: number;
42
+ source?: BucketTransitionSource;
43
+ }): Promise<void> {
44
+ const {
45
+ db,
46
+ registry,
47
+ hatchet,
48
+ logger,
49
+ kind,
50
+ bucket,
51
+ userId,
52
+ userEmail,
53
+ epoch,
54
+ source = "event",
55
+ } = opts;
56
+
57
+ const properties: Record<string, unknown> = {
58
+ bucketId: bucket.id,
59
+ bucketName: bucket.name,
60
+ userId,
61
+ transition: kind,
62
+ source,
63
+ };
64
+
65
+ // Optional PostHog person-property mirror (Section 12). Off by default; a
66
+ // no-op without POSTHOG_API_KEY. Wired here, the single transition path shared
67
+ // by all three producers (real-time / reconcile / fast-expiry), so the sync
68
+ // fires exactly once per emitted transition. Best-effort — it never blocks the
69
+ // event emit below.
70
+ syncBucketToPostHog({ logger, kind, bucket, userId });
71
+
72
+ // Per-bucket alias — the recommended, narrowly-routed binding. The
73
+ // deterministic idempotencyKey rides the userEvents dedup short-circuit as
74
+ // defense-in-depth (Section 6.3).
75
+ await ingestEvent({
76
+ db,
77
+ registry,
78
+ hatchet,
79
+ logger,
80
+ event: {
81
+ event: `bucket:${kind}:${bucket.id}`,
82
+ userId,
83
+ userEmail: userEmail ?? "",
84
+ properties,
85
+ idempotencyKey: `bucket:${bucket.id}:${userId}:${kind}:${epoch}`,
86
+ },
87
+ });
88
+
89
+ // Generic form — emitted ONLY if a journey actually binds to it, so the
90
+ // recursion-guarded generic event is not written for nothing (Section 8.5).
91
+ const genericEvent = `bucket:${kind}`;
92
+ if (registry.getByTriggerEvent(genericEvent).length > 0) {
93
+ await ingestEvent({
94
+ db,
95
+ registry,
96
+ hatchet,
97
+ logger,
98
+ event: {
99
+ event: genericEvent,
100
+ userId,
101
+ userEmail: userEmail ?? "",
102
+ properties,
103
+ idempotencyKey: `bucket:${bucket.id}:${userId}:${kind}:${epoch}:generic`,
104
+ },
105
+ });
106
+ }
107
+ }
@@ -0,0 +1,63 @@
1
+ import type { BucketMeta } from "@hogsend/core";
2
+ import type { Logger } from "./logger.js";
3
+ import { getPostHog } from "./posthog.js";
4
+
5
+ /**
6
+ * Optional PostHog person-property mirror for a bucket transition (Section 12).
7
+ *
8
+ * OFF BY DEFAULT — a no-op unless `meta.syncToPostHog === true`. Also a no-op
9
+ * without `POSTHOG_API_KEY` (`getPostHog()` returns undefined), so self-host
10
+ * setups that omit PostHog silently do nothing — documented, not broken.
11
+ *
12
+ * On JOIN it `$set`s a boolean person property `true`; on LEAVE it `$unset`s the
13
+ * same key. `$unset` (NOT `$set false`) is the recommended default: a cohort
14
+ * `key = true` excludes a false value, but a cohort `key is set` STILL matches a
15
+ * false value, so `$unset` is the only form where both cohort idioms behave
16
+ * correctly. The property key defaults to `hogsend_bucket_<id>`, overridable via
17
+ * `meta.postHogPropertyKey`.
18
+ *
19
+ * This reuses the existing `plugin-posthog` capture path (the same one
20
+ * `identify()` uses at journey-context.ts) — it adds no new integration surface
21
+ * and never pushes to any non-PostHog destination (the Section 2.4 anti-CDP
22
+ * invariant). Best-effort: a capture failure is logged and swallowed so a sync
23
+ * error never blocks a transition emit.
24
+ */
25
+ export function syncBucketToPostHog(opts: {
26
+ logger: Logger;
27
+ kind: "entered" | "left";
28
+ bucket: BucketMeta;
29
+ userId: string;
30
+ }): void {
31
+ const { logger, kind, bucket, userId } = opts;
32
+
33
+ if (!bucket.syncToPostHog) return;
34
+
35
+ const posthog = getPostHog();
36
+ if (!posthog) return; // no POSTHOG_API_KEY → silent no-op
37
+
38
+ const propertyKey =
39
+ bucket.postHogPropertyKey ?? `hogsend_bucket_${bucket.id}`;
40
+
41
+ try {
42
+ if (kind === "entered") {
43
+ // $set { key: true } — mirrors plugin-posthog identify() ($set path).
44
+ posthog.identify(userId, { [propertyKey]: true });
45
+ } else {
46
+ // $unset [key] — RECOMMENDED on leave (Section 12). The property is absent
47
+ // unless the user is currently a member, so both `key = true` and
48
+ // `key is set` cohorts behave correctly.
49
+ posthog.captureEvent({
50
+ distinctId: userId,
51
+ event: "$set",
52
+ properties: { $unset: [propertyKey] },
53
+ });
54
+ }
55
+ } catch (err) {
56
+ logger.warn("Bucket PostHog sync failed (best-effort)", {
57
+ bucketId: bucket.id,
58
+ userId,
59
+ kind,
60
+ error: err instanceof Error ? err.message : String(err),
61
+ });
62
+ }
63
+ }
@@ -3,6 +3,7 @@ import { evaluatePropertyConditions } from "@hogsend/core";
3
3
  import type { JourneyRegistry } from "@hogsend/core/registry";
4
4
  import { type Database, journeyStates, userEvents } from "@hogsend/db";
5
5
  import { and, eq, inArray, isNull } from "drizzle-orm";
6
+ import { checkBucketMembership } from "../buckets/check-membership.js";
6
7
  import { upsertContact } from "./contacts.js";
7
8
  import type { Logger } from "./logger.js";
8
9
 
@@ -93,6 +94,30 @@ export async function ingestEvent(opts: {
93
94
  }),
94
95
  ]);
95
96
 
97
+ // Real-time bucket membership re-evaluation (Section 6.1). NOT part of the
98
+ // Promise.all above: its property eval reads MERGED contact state, and its
99
+ // bucket:entered/left emissions recurse back into ingestEvent (the recursion
100
+ // guard in checkBucketMembership bounds them). Best-effort: a bucket failure
101
+ // must not fail the ingest of the originating event.
102
+ try {
103
+ await checkBucketMembership({
104
+ db,
105
+ registry,
106
+ hatchet,
107
+ logger,
108
+ userId: event.userId,
109
+ userEmail: event.userEmail || null,
110
+ event: event.event,
111
+ properties: event.properties,
112
+ });
113
+ } catch (err) {
114
+ logger.warn("Bucket membership check failed", {
115
+ event: event.event,
116
+ userId: event.userId,
117
+ error: err instanceof Error ? err.message : String(err),
118
+ });
119
+ }
120
+
96
121
  logger.info("Event ingested", {
97
122
  event: event.event,
98
123
  userId: event.userId,