@hogsend/engine 0.6.0 → 0.8.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.
Files changed (60) hide show
  1. package/package.json +7 -6
  2. package/src/app.ts +36 -1
  3. package/src/buckets/check-membership.ts +34 -15
  4. package/src/container.ts +33 -0
  5. package/src/env.ts +29 -0
  6. package/src/index.ts +47 -1
  7. package/src/journeys/define-journey.ts +26 -2
  8. package/src/journeys/journey-context.ts +5 -1
  9. package/src/lib/boot.ts +1 -1
  10. package/src/lib/bucket-emit.ts +47 -2
  11. package/src/lib/contacts.ts +1105 -18
  12. package/src/lib/email-service-types.ts +8 -0
  13. package/src/lib/ingestion.ts +63 -33
  14. package/src/lib/mailer.ts +88 -0
  15. package/src/lib/outbound.ts +216 -0
  16. package/src/lib/preferences.ts +137 -0
  17. package/src/lib/tracked.ts +204 -37
  18. package/src/lib/tracking-events.ts +67 -2
  19. package/src/lib/webhook-signing.ts +151 -0
  20. package/src/lists/define-list.ts +81 -0
  21. package/src/lists/registry-singleton.ts +39 -0
  22. package/src/lists/registry.ts +95 -0
  23. package/src/middleware/api-key.ts +33 -7
  24. package/src/middleware/rate-limit.ts +73 -49
  25. package/src/routes/_shared.ts +30 -0
  26. package/src/routes/admin/api-keys.ts +1 -1
  27. package/src/routes/admin/bulk.ts +7 -3
  28. package/src/routes/admin/contacts.ts +108 -59
  29. package/src/routes/admin/events.ts +65 -0
  30. package/src/routes/admin/index.ts +2 -0
  31. package/src/routes/admin/journeys.ts +3 -1
  32. package/src/routes/admin/preferences.ts +2 -2
  33. package/src/routes/admin/reporting.ts +3 -3
  34. package/src/routes/admin/timeline.ts +5 -2
  35. package/src/routes/admin/webhooks.ts +466 -0
  36. package/src/routes/campaigns/index.ts +252 -0
  37. package/src/routes/contacts/index.ts +231 -0
  38. package/src/routes/email/preferences.ts +27 -3
  39. package/src/routes/email/unsubscribe.ts +7 -49
  40. package/src/routes/emails/index.ts +133 -0
  41. package/src/routes/events/index.ts +119 -0
  42. package/src/routes/index.ts +52 -2
  43. package/src/routes/lists/index.ts +258 -0
  44. package/src/routes/tracking/click.ts +59 -18
  45. package/src/routes/tracking/open.ts +62 -24
  46. package/src/routes/webhooks/sources.ts +69 -10
  47. package/src/webhook-sources/define-webhook-source.ts +57 -5
  48. package/src/webhook-sources/presets/clerk.ts +185 -0
  49. package/src/webhook-sources/presets/index.ts +80 -0
  50. package/src/webhook-sources/presets/segment.ts +120 -0
  51. package/src/webhook-sources/presets/stripe.ts +147 -0
  52. package/src/webhook-sources/presets/supabase.ts +131 -0
  53. package/src/webhook-sources/verify.ts +172 -0
  54. package/src/worker.ts +12 -0
  55. package/src/workflows/bucket-backfill.ts +32 -21
  56. package/src/workflows/bucket-reconcile.ts +20 -5
  57. package/src/workflows/deliver-webhook.ts +399 -0
  58. package/src/workflows/import-contacts.ts +28 -20
  59. package/src/workflows/send-campaign.ts +589 -0
  60. package/src/routes/ingest.ts +0 -71
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/engine",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,13 +35,14 @@
35
35
  "papaparse": "^5.5.3",
36
36
  "picocolors": "^1.1.1",
37
37
  "resend": "^6.12.3",
38
+ "svix": "^1.95.1",
38
39
  "winston": "^3.19.0",
39
40
  "zod": "^4.4.3",
40
- "@hogsend/core": "^0.6.0",
41
- "@hogsend/db": "^0.6.0",
42
- "@hogsend/email": "^0.6.0",
43
- "@hogsend/plugin-posthog": "^0.6.0",
44
- "@hogsend/plugin-resend": "^0.6.0"
41
+ "@hogsend/core": "^0.8.0",
42
+ "@hogsend/email": "^0.8.0",
43
+ "@hogsend/plugin-posthog": "^0.8.0",
44
+ "@hogsend/plugin-resend": "^0.8.0",
45
+ "@hogsend/db": "^0.8.0"
45
46
  },
46
47
  "devDependencies": {
47
48
  "@types/node": "^22.15.3",
package/src/app.ts CHANGED
@@ -15,6 +15,7 @@ import { errorHandler } from "./middleware/error-handler.js";
15
15
  import { requestLogger } from "./middleware/request-logger.js";
16
16
  import { registerRoutes } from "./routes/index.js";
17
17
  import type { DefinedWebhookSource } from "./webhook-sources/define-webhook-source.js";
18
+ import { presetsFromEnv } from "./webhook-sources/presets/index.js";
18
19
 
19
20
  type AuthSession = Awaited<ReturnType<Auth["api"]["getSession"]>>;
20
21
 
@@ -35,10 +36,32 @@ export interface CreateAppOptions {
35
36
  middleware?: MiddlewareHandler[];
36
37
  /** Webhook sources served at `/v1/webhooks/:sourceId`. */
37
38
  webhookSources?: DefinedWebhookSource[];
39
+ /**
40
+ * Auto-enable the shipped integration presets (Clerk, Supabase, Stripe,
41
+ * Segment) for every preset whose env secret is configured (gated further by
42
+ * `ENABLED_WEBHOOK_PRESETS`). Consumer-supplied `webhookSources` always win on
43
+ * an id collision. Set `false` to opt out entirely. Default `true`.
44
+ */
45
+ enablePresets?: boolean;
38
46
  /** Override the default error handler. */
39
47
  onError?: ErrorHandler<AppEnv>;
40
48
  }
41
49
 
50
+ /**
51
+ * Merge env-enabled presets with the consumer's explicit sources. The
52
+ * consumer-supplied source WINS on an id collision (so a hand-tuned override of
53
+ * a preset replaces the shipped one rather than registering a duplicate route).
54
+ */
55
+ function dedupeById(sources: DefinedWebhookSource[]): DefinedWebhookSource[] {
56
+ const byId = new Map<string, DefinedWebhookSource>();
57
+ for (const source of sources) {
58
+ // Last write wins; callers order presets BEFORE consumer sources so the
59
+ // consumer override lands last.
60
+ byId.set(source.meta.id, source);
61
+ }
62
+ return [...byId.values()];
63
+ }
64
+
42
65
  export function createApp(
43
66
  container: HogsendClient,
44
67
  opts: CreateAppOptions = {},
@@ -96,7 +119,19 @@ export function createApp(
96
119
  return c.json({ needsSetup: existing.length === 0 });
97
120
  });
98
121
 
99
- registerRoutes(app, { webhookSources: opts.webhookSources ?? [] });
122
+ // Merge env-enabled presets ahead of the consumer's explicit sources so a
123
+ // consumer override of a preset id wins (decision #13). `enablePresets`
124
+ // defaults true; setting only `STRIPE_WEBHOOK_SECRET` auto-mounts Stripe at
125
+ // `POST /v1/webhooks/stripe` and nothing else.
126
+ const enablePresets = opts.enablePresets ?? true;
127
+ const webhookSources = enablePresets
128
+ ? dedupeById([
129
+ ...presetsFromEnv(container.env),
130
+ ...(opts.webhookSources ?? []),
131
+ ])
132
+ : (opts.webhookSources ?? []);
133
+
134
+ registerRoutes(app, { webhookSources });
100
135
 
101
136
  // Serve the Studio SPA at /studio/* (static layer, no auth — the SPA gates
102
137
  // itself via /v1/auth/status + login; data endpoints stay behind requireAdmin).
@@ -51,7 +51,16 @@ export async function checkBucketMembership(opts: {
51
51
  userId: string;
52
52
  userEmail: string | null;
53
53
  event: string;
54
- properties: Record<string, unknown>;
54
+ /**
55
+ * D2: the event payload — candidate-narrowing ONLY. It NO LONGER participates
56
+ * in property eval (the raw-payload overlay was the bucket-side conflation).
57
+ */
58
+ eventProperties: Record<string, unknown>;
59
+ /**
60
+ * D2: this-ingest contact-property patch, overlaid on the read contact row so
61
+ * the very first event after a property change evaluates correctly (risk 7).
62
+ */
63
+ contactProperties?: Record<string, unknown>;
55
64
  /** Optional override; defaults to the process bucket-registry singleton. */
56
65
  bucketRegistry?: ReturnType<typeof getBucketRegistrySingleton>;
57
66
  }): Promise<BucketTransition[]> {
@@ -63,7 +72,8 @@ export async function checkBucketMembership(opts: {
63
72
  userId,
64
73
  userEmail,
65
74
  event,
66
- properties,
75
+ eventProperties,
76
+ contactProperties: contactPropertiesPatch,
67
77
  } = opts;
68
78
 
69
79
  // (1) Recursion guard — MUST be first. bucket:-prefixed events are transition
@@ -81,12 +91,18 @@ export async function checkBucketMembership(opts: {
81
91
 
82
92
  // (2) Candidate narrowing — the UNION of buckets referencing this event name
83
93
  // (eventIndex + the degenerate wildcard set) and buckets referencing any
84
- // property present in this payload (propertyIndex). Section 6.2.
94
+ // property present in EITHER bag (propertyIndex): the eventProperties drive
95
+ // event-shaped criteria narrowing, the contactProperties patch surfaces a
96
+ // contact-property change so a property-criteria bucket is re-checked on the
97
+ // first event that mutates it. Section 6.2.
85
98
  const candidateMap = new Map<string, BucketMeta>();
86
99
  for (const bucket of bucketRegistry.getByReferencedEvent(event)) {
87
100
  candidateMap.set(bucket.id, bucket);
88
101
  }
89
- for (const key of Object.keys(properties ?? {})) {
102
+ for (const key of [
103
+ ...Object.keys(eventProperties ?? {}),
104
+ ...Object.keys(contactPropertiesPatch ?? {}),
105
+ ]) {
90
106
  for (const bucket of bucketRegistry.getByReferencedProperty(key)) {
91
107
  candidateMap.set(bucket.id, bucket);
92
108
  }
@@ -109,19 +125,20 @@ export async function checkBucketMembership(opts: {
109
125
  return [];
110
126
  }
111
127
 
112
- // (3) Property predicates evaluate against MERGED contact state, NOT the bare
113
- // event payload (Section 6.1 rule #3). Read the EXISTING contacts row ONCE iff
114
- // any surviving candidate references a property pure event/count buckets skip
115
- // the read entirely. We read the row that already exists (not the one
116
- // upsertContact is concurrently writing) so we do not depend on the
117
- // fire-and-forget upsert having run.
128
+ // (3) Property predicates evaluate against contact state this-ingest
129
+ // contactProperties patch — NOT the raw event payload (Section 6.1 rule #3 /
130
+ // D2). Read the EXISTING contacts row ONCE iff any surviving candidate
131
+ // references a property pure event/count buckets skip the read entirely.
132
+ // `ingestEvent` already awaited `resolveOrCreateContact` before us, so the row
133
+ // exists by the resolved key; the patch overlay still covers the read-after-
134
+ // write gap on a contact's very first event (risk 7).
118
135
  const needsContactState = candidates.some(
119
136
  (bucket) =>
120
137
  bucket.criteria != null &&
121
138
  collectPropertyNames(bucket.criteria).length > 0,
122
139
  );
123
140
 
124
- let contactProperties: Record<string, unknown> = {};
141
+ let storedContactProps: Record<string, unknown> = {};
125
142
  let contactDeleted = false;
126
143
  if (needsContactState) {
127
144
  const [contact] = await db
@@ -133,7 +150,7 @@ export async function checkBucketMembership(opts: {
133
150
  .where(eq(contacts.externalId, userId))
134
151
  .limit(1);
135
152
  if (contact) {
136
- contactProperties =
153
+ storedContactProps =
137
154
  (contact.properties as Record<string, unknown> | null) ?? {};
138
155
  contactDeleted = contact.deletedAt != null;
139
156
  }
@@ -144,10 +161,12 @@ export async function checkBucketMembership(opts: {
144
161
  return [];
145
162
  }
146
163
 
147
- // event payload overlays cumulative contact state.
164
+ // this-ingest contactProperties patch overlays stored contact state. The raw
165
+ // event payload is REMOVED from property eval (D2 — bucket prop-criteria see
166
+ // contact state only).
148
167
  const journeyContext: Record<string, unknown> = {
149
- ...contactProperties,
150
- ...(properties ?? {}),
168
+ ...storedContactProps,
169
+ ...(contactPropertiesPatch ?? {}),
151
170
  };
152
171
 
153
172
  const transitions: BucketTransition[] = [];
package/src/container.ts CHANGED
@@ -36,6 +36,8 @@ import { createLogger, type Logger } from "./lib/logger.js";
36
36
  import { createTrackedMailer } from "./lib/mailer.js";
37
37
  import { getPostHog } from "./lib/posthog.js";
38
38
  import { prepareTrackedHtml } from "./lib/tracking.js";
39
+ import type { DefinedList } from "./lists/define-list.js";
40
+ import { buildListRegistry, type ListRegistry } from "./lists/registry.js";
39
41
 
40
42
  export interface HogsendDefaults {
41
43
  /** Global fallback IANA timezone for scheduling. Defaults to "UTC". */
@@ -70,6 +72,14 @@ export interface HogsendClient {
70
72
  * Empty when no buckets are wired.
71
73
  */
72
74
  bucketRegistry: BucketRegistry;
75
+ /**
76
+ * The email-list registry (D3): code-defined subscription categories layered
77
+ * on `email_preferences.categories`, with the LOCKED polarity rule that is the
78
+ * single source of truth for the mailer's suppression check AND the preference
79
+ * center. Built and installed as the process singleton at client build (read
80
+ * elsewhere via `getListRegistry()`). Empty when no lists are wired.
81
+ */
82
+ listRegistry: ListRegistry;
73
83
  hatchet: HatchetClient;
74
84
  /**
75
85
  * The client repo's migration journal (`migrations/meta/_journal.json`),
@@ -91,6 +101,13 @@ export interface HogsendClientOptions {
91
101
  journeys?: DefinedJourney[];
92
102
  /** Buckets to register in the {@link BucketRegistry}. Defaults to none. */
93
103
  buckets?: DefinedBucket[];
104
+ /**
105
+ * Email lists (D3) to register in the {@link ListRegistry}. Each is a
106
+ * `defineList()` subscription category (id + name + `defaultOptIn`). The
107
+ * registry drives the mailer's list-aware suppression check and the
108
+ * preference center. Defaults to none (empty registry ⇒ legacy opt-in).
109
+ */
110
+ lists?: DefinedList[];
94
111
  /**
95
112
  * Email is a first-class channel. Its config is grouped here rather than
96
113
  * spread across top-level args — the engine owns the cohesive email pipeline
@@ -131,6 +148,11 @@ export interface HogsendClientOptions {
131
148
  * `env.ENABLED_BUCKETS`.
132
149
  */
133
150
  enabledBuckets?: string;
151
+ /**
152
+ * Comma-separated ids (or `*`) controlling which lists load. Defaults to
153
+ * `env.ENABLED_LISTS`.
154
+ */
155
+ enabledLists?: string;
134
156
  /**
135
157
  * The client repo's migration journal for the `schema.client` health block.
136
158
  * Defaults to `{ entries: [] }` (empty client track ⇒ trivially in sync).
@@ -237,6 +259,15 @@ export function createHogsendClient(
237
259
  bucket.membersIterator = accessor.membersIterator;
238
260
  }
239
261
 
262
+ // Build + install the list registry singleton (D3). Runs in BOTH the API and
263
+ // worker (both call createHogsendClient), so `getListRegistry()` resolves the
264
+ // wired lists in the mailer's suppression check and the preference center in
265
+ // either process. `buildListRegistry` installs the process singleton.
266
+ const listRegistry = buildListRegistry(
267
+ opts.lists ?? [],
268
+ opts.enabledLists ?? env.ENABLED_LISTS,
269
+ );
270
+
240
271
  const provider =
241
272
  opts.email?.provider ??
242
273
  createResendProvider({
@@ -293,6 +324,7 @@ export function createHogsendClient(
293
324
  // keep these at debug for non-boot contexts (tests, REPL, library use).
294
325
  logger.debug(`Journey registry loaded: ${registry.count()} journeys`);
295
326
  logger.debug(`Bucket registry loaded: ${bucketRegistry.count()} buckets`);
327
+ logger.debug(`List registry loaded: ${listRegistry.count()} lists`);
296
328
 
297
329
  return {
298
330
  env,
@@ -306,6 +338,7 @@ export function createHogsendClient(
306
338
  analytics,
307
339
  registry,
308
340
  bucketRegistry,
341
+ listRegistry,
309
342
  hatchet: opts.overrides?.hatchet ?? hatchet,
310
343
  clientJournal: opts.clientJournal ?? { entries: [] },
311
344
  defaults,
package/src/env.ts CHANGED
@@ -65,8 +65,37 @@ export const env = createEnv({
65
65
  // Evaluated at worker boot — a toggle requires a worker restart; only the
66
66
  // bucket_configs DB override is hot.
67
67
  ENABLED_BUCKETS: z.string().default("*"),
68
+ // Email lists (D3): same `"*"`-or-csv contract as ENABLED_JOURNEYS /
69
+ // ENABLED_BUCKETS. Filters which `defineList()` lists are registered into the
70
+ // process ListRegistry (the suppression-polarity + preference-center source).
71
+ ENABLED_LISTS: z.string().default("*"),
68
72
  // Cadence for the engine-owned bucket reconcile cron (time-based leaves).
69
73
  BUCKET_RECONCILE_CRON: z.string().default("*/5 * * * *"),
74
+ // --- Outbound webhooks (Section 1.5/1.8) ---
75
+ // Cadence for the engine-owned outbound-delivery reaper cron (the retry
76
+ // scheduler + orphan-`sending` recovery). Declared for parity with
77
+ // BUCKET_RECONCILE_CRON; the delivery task also reads it raw off process.env.
78
+ OUTBOUND_WEBHOOK_REAPER_CRON: z.string().optional(),
79
+ // Delivery tunables — read raw off process.env inside the durable task;
80
+ // declared here so they are part of the validated env contract. All optional
81
+ // with task-internal defaults (MAX_ATTEMPTS 8, TIMEOUT 15s, BASE 5s,
82
+ // MAX_DELAY 6h, STUCK_AFTER 5min).
83
+ OUTBOUND_WEBHOOK_MAX_ATTEMPTS: z.coerce.number().optional(),
84
+ OUTBOUND_WEBHOOK_TIMEOUT_MS: z.coerce.number().optional(),
85
+ OUTBOUND_WEBHOOK_BASE_DELAY_MS: z.coerce.number().optional(),
86
+ OUTBOUND_WEBHOOK_MAX_DELAY_MS: z.coerce.number().optional(),
87
+ OUTBOUND_WEBHOOK_STUCK_AFTER_MS: z.coerce.number().optional(),
88
+ // --- Integration presets (Section 2.2) ---
89
+ // Signature-source secrets. The webhook route resolves a preset's secret via
90
+ // env[source.auth.envKey]; a signature source FAILS CLOSED when its secret is
91
+ // unset. Setting one auto-enables that preset at POST /v1/webhooks/<id>.
92
+ CLERK_WEBHOOK_SECRET: z.string().min(1).optional(),
93
+ SUPABASE_WEBHOOK_SECRET: z.string().min(1).optional(),
94
+ STRIPE_WEBHOOK_SECRET: z.string().min(1).optional(),
95
+ SEGMENT_WEBHOOK_SECRET: z.string().min(1).optional(),
96
+ // Preset enablement override: csv of preset ids, `"*"` (all with a secret),
97
+ // or `"none"`. Absent → auto-enable any preset whose secret is set.
98
+ ENABLED_WEBHOOK_PRESETS: z.string().optional(),
70
99
  },
71
100
  runtimeEnv: process.env,
72
101
  emptyStringAsUndefined: true,
package/src/index.ts CHANGED
@@ -16,7 +16,6 @@ export type {
16
16
  PostHogService,
17
17
  SendResult,
18
18
  WebhookEvent,
19
- WebhookEventType,
20
19
  WebhookHandlerMap,
21
20
  } from "@hogsend/core";
22
21
  // Core helpers used by content journeys (days/hours/minutes, condition + journey
@@ -150,6 +149,13 @@ export {
150
149
  // --- Logging ---
151
150
  export { createLogger, type Logger } from "./lib/logger.js";
152
151
  export { createTrackedMailer } from "./lib/mailer.js";
152
+ // --- Outbound webhooks: emit spine (Section 1.4) ---
153
+ export {
154
+ emitOutbound,
155
+ OUTBOUND_EVENTS,
156
+ type OutboundEventName,
157
+ type OutboundPayloads,
158
+ } from "./lib/outbound.js";
153
159
  export { getPostHog } from "./lib/posthog.js";
154
160
  export { getRedisIfConnected } from "./lib/redis.js";
155
161
  export { type MountStudioResult, mountStudio } from "./lib/studio.js";
@@ -174,14 +180,48 @@ export {
174
180
  export {
175
181
  pushTrackingEvent,
176
182
  resolveEmailSendContext,
183
+ resolveEmailSendContextByResendId,
177
184
  } from "./lib/tracking-events.js";
185
+ // --- Outbound webhooks: signing core (Section 1.2) ---
186
+ export {
187
+ generateWebhookSecret,
188
+ type SignedWebhook,
189
+ signWebhook,
190
+ verifyWebhookSignature,
191
+ WEBHOOK_EVENT_TYPES,
192
+ type WebhookEventType,
193
+ } from "./lib/webhook-signing.js";
194
+ // --- Lists (D3) ---
195
+ export {
196
+ type DefinedList,
197
+ defineList,
198
+ type ListMeta,
199
+ } from "./lists/define-list.js";
200
+ export { buildListRegistry, ListRegistry } from "./lists/registry.js";
201
+ export {
202
+ getListRegistry,
203
+ resetListRegistry,
204
+ setListRegistry,
205
+ } from "./lists/registry-singleton.js";
178
206
  // --- Webhook sources ---
179
207
  export {
180
208
  type DefinedWebhookSource,
181
209
  defineWebhookSource,
210
+ verifySignature,
211
+ type WebhookSourceAuth,
182
212
  type WebhookSourceCtx,
183
213
  type WebhookSourceMeta,
184
214
  } from "./webhook-sources/define-webhook-source.js";
215
+ // --- Integration presets (Section 2.3/2.4) ---
216
+ export {
217
+ clerkSource,
218
+ PRESET_SOURCES,
219
+ type PresetId,
220
+ presetsFromEnv,
221
+ segmentSource,
222
+ stripeSource,
223
+ supabaseSource,
224
+ } from "./webhook-sources/presets/index.js";
185
225
  export {
186
226
  type CreateWorkerOptions,
187
227
  createWorker,
@@ -199,6 +239,12 @@ export {
199
239
  bucketReconcileTask,
200
240
  } from "./workflows/bucket-reconcile.js";
201
241
  export { checkAlertsTask } from "./workflows/check-alerts.js";
242
+ // --- Outbound webhooks: durable delivery task + reaper (Section 1.5) ---
243
+ export {
244
+ deliverWebhookTask,
245
+ reapDueWebhookDeliveriesTask,
246
+ } from "./workflows/deliver-webhook.js";
202
247
  export { importContactsTask } from "./workflows/import-contacts.js";
248
+ export { sendCampaignTask } from "./workflows/send-campaign.js";
203
249
  // --- Built-in Hatchet workflow tasks ---
204
250
  export { sendEmailTask } from "./workflows/send-email.js";
@@ -15,6 +15,7 @@ import {
15
15
  } from "../lib/enrollment-guards.js";
16
16
  import { hatchet } from "../lib/hatchet.js";
17
17
  import { createLogger } from "../lib/logger.js";
18
+ import { emitOutbound } from "../lib/outbound.js";
18
19
  import { resolveTimezoneWithSource } from "../lib/timezone.js";
19
20
  import { getClientScheduleDefaults } from "./client-defaults-singleton.js";
20
21
  import { JOURNEY_EXECUTION_TIMEOUT } from "./constants.js";
@@ -190,12 +191,13 @@ export function defineJourney(options: {
190
191
  try {
191
192
  await options.run(user, ctx);
192
193
 
194
+ const completedAt = new Date();
193
195
  await db
194
196
  .update(journeyStates)
195
197
  .set({
196
198
  status: "completed",
197
- completedAt: new Date(),
198
- updatedAt: new Date(),
199
+ completedAt,
200
+ updatedAt: completedAt,
199
201
  })
200
202
  .where(eq(journeyStates.id, stateId));
201
203
 
@@ -205,6 +207,28 @@ export function defineJourney(options: {
205
207
  userId,
206
208
  });
207
209
 
210
+ // OUTBOUND `journey.completed` — fired alongside the internal
211
+ // `journey:completed` push. Runs in the WORKER (this durable task), so it
212
+ // uses the engine `db`/`hatchet`/`logger` singletons. `dedupeKey` =
213
+ // `journey.completed:<stateId>`: a Hatchet re-execution recomputes the
214
+ // identical key and the unique `(endpointId, dedupeKey)` index absorbs the
215
+ // duplicate (risk 3). `journey:failed` is NOT in the catalog → no emit.
216
+ void emitOutbound({
217
+ db,
218
+ hatchet,
219
+ logger,
220
+ event: "journey.completed",
221
+ dedupeKey: `journey.completed:${stateId}`,
222
+ payload: {
223
+ journeyId: meta.id,
224
+ journeyName: meta.name,
225
+ stateId,
226
+ userId,
227
+ userEmail,
228
+ completedAt: completedAt.toISOString(),
229
+ },
230
+ }).catch(logger.warn);
231
+
208
232
  return { stateId, status: "completed" };
209
233
  } catch (err) {
210
234
  // The journey reached a terminal state (exitOn / cancel) while suspended
@@ -298,6 +298,10 @@ export function createJourneyContext(
298
298
  userEmail: targetEmail,
299
299
  properties,
300
300
  }) {
301
+ // Keep the PUBLIC `TriggerOptions.properties` field name (decision #13 —
302
+ // renaming it would break consumer journeys + scaffold). Map it to the
303
+ // engine-internal `eventProperties` bag here; no `contactProperties` by
304
+ // default (a future `TriggerOptions.contactProperties` is deferred).
301
305
  await ingestEvent({
302
306
  db,
303
307
  registry,
@@ -307,7 +311,7 @@ export function createJourneyContext(
307
311
  event,
308
312
  userId: targetUserId,
309
313
  userEmail: targetEmail ?? userEmail,
310
- properties: properties ?? {},
314
+ eventProperties: properties ?? {},
311
315
  },
312
316
  });
313
317
  },
package/src/lib/boot.ts CHANGED
@@ -171,6 +171,6 @@ export function reportWorkerReady(info: WorkerReadyInfo): void {
171
171
  ` ${ok} ${tasks}`,
172
172
  "",
173
173
  ` ${dim("Listening — journeys fire as events arrive.")}`,
174
- ` ${dim("Send one:")} ${color.cyan("POST /v1/ingest")} ${dim("· or Studio › Debug")}`,
174
+ ` ${dim("Send one:")} ${color.cyan("POST /v1/events")} ${dim("· or Studio › Debug")}`,
175
175
  ]);
176
176
  }
@@ -6,6 +6,7 @@ import type { BucketLeaveReason } from "../buckets/bucket-reactions.js";
6
6
  import { syncBucketToPostHog } from "./bucket-posthog-sync.js";
7
7
  import { ingestEvent } from "./ingestion.js";
8
8
  import type { Logger } from "./logger.js";
9
+ import { emitOutbound } from "./outbound.js";
9
10
 
10
11
  export type BucketTransitionKind = "entered" | "left" | "dwell";
11
12
 
@@ -123,7 +124,7 @@ export async function emitBucketTransition(opts: {
123
124
  event: eventName,
124
125
  userId,
125
126
  userEmail: userEmail ?? "",
126
- properties,
127
+ eventProperties: properties,
127
128
  idempotencyKey,
128
129
  },
129
130
  });
@@ -141,9 +142,53 @@ export async function emitBucketTransition(opts: {
141
142
  event: genericEvent,
142
143
  userId,
143
144
  userEmail: userEmail ?? "",
144
- properties,
145
+ eventProperties: properties,
145
146
  idempotencyKey: `bucket:${bucket.id}:${userId}:${kind}:${epoch}:generic`,
146
147
  },
147
148
  });
148
149
  }
150
+
151
+ // OUTBOUND `bucket.entered` / `bucket.left` — this is the SINGLE producer for
152
+ // all three transition origins (real-time / reconcile / fast-expiry), so the
153
+ // emit lives here once. GATED to enter/leave (dwell is a recurring membership
154
+ // tick, not in the catalog — same gate as the PostHog mirror above). The
155
+ // `dedupeKey` is the SAME deterministic `idempotencyKey` all three producers
156
+ // compute, so a Hatchet re-execution converges to ONE emission via the unique
157
+ // `(endpointId, dedupeKey)` index (risk 3). Fire-and-forget.
158
+ if (kind === "entered") {
159
+ void emitOutbound({
160
+ db,
161
+ hatchet,
162
+ logger,
163
+ event: "bucket.entered",
164
+ dedupeKey: idempotencyKey,
165
+ payload: {
166
+ bucketId: bucket.id,
167
+ bucketName: bucket.name,
168
+ userId,
169
+ userEmail,
170
+ transition: "entered",
171
+ entryCount: epoch,
172
+ source,
173
+ },
174
+ }).catch(logger.warn);
175
+ } else if (kind === "left") {
176
+ void emitOutbound({
177
+ db,
178
+ hatchet,
179
+ logger,
180
+ event: "bucket.left",
181
+ dedupeKey: idempotencyKey,
182
+ payload: {
183
+ bucketId: bucket.id,
184
+ bucketName: bucket.name,
185
+ userId,
186
+ userEmail,
187
+ transition: "left",
188
+ entryCount: epoch,
189
+ source,
190
+ ...(reason != null ? { reason } : {}),
191
+ },
192
+ }).catch(logger.warn);
193
+ }
149
194
  }