@hogsend/engine 0.5.0 → 0.7.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 (48) hide show
  1. package/package.json +6 -6
  2. package/src/buckets/bucket-access.ts +213 -0
  3. package/src/buckets/bucket-reactions.ts +225 -0
  4. package/src/buckets/check-membership.ts +35 -15
  5. package/src/buckets/define-bucket.ts +79 -8
  6. package/src/buckets/registry.ts +81 -0
  7. package/src/container.ts +69 -4
  8. package/src/env.ts +4 -0
  9. package/src/index.ts +27 -0
  10. package/src/journeys/journey-context.ts +5 -1
  11. package/src/lib/boot.ts +12 -2
  12. package/src/lib/bucket-emit.ts +49 -7
  13. package/src/lib/contacts.ts +1083 -18
  14. package/src/lib/email-service-types.ts +8 -0
  15. package/src/lib/ingestion.ts +63 -33
  16. package/src/lib/mailer.ts +1 -0
  17. package/src/lib/preferences.ts +106 -0
  18. package/src/lib/tracked.ts +159 -34
  19. package/src/lib/tracking-events.ts +1 -1
  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/buckets.ts +39 -9
  28. package/src/routes/admin/bulk.ts +7 -3
  29. package/src/routes/admin/contacts.ts +66 -57
  30. package/src/routes/admin/events.ts +65 -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/campaigns/index.ts +252 -0
  36. package/src/routes/contacts/index.ts +188 -0
  37. package/src/routes/email/preferences.ts +27 -3
  38. package/src/routes/email/unsubscribe.ts +7 -49
  39. package/src/routes/emails/index.ts +133 -0
  40. package/src/routes/events/index.ts +119 -0
  41. package/src/routes/index.ts +52 -2
  42. package/src/routes/lists/index.ts +222 -0
  43. package/src/worker.ts +25 -2
  44. package/src/workflows/bucket-backfill.ts +122 -22
  45. package/src/workflows/bucket-reconcile.ts +225 -12
  46. package/src/workflows/import-contacts.ts +28 -20
  47. package/src/workflows/send-campaign.ts +589 -0
  48. package/src/routes/ingest.ts +0 -71
@@ -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
@@ -14,8 +14,12 @@ import {
14
14
  createResendProvider,
15
15
  } from "@hogsend/plugin-resend";
16
16
  import type { Resend } from "resend";
17
+ import { createBucketAccessor } from "./buckets/bucket-access.js";
17
18
  import type { DefinedBucket } from "./buckets/define-bucket.js";
18
- import { buildBucketRegistry } from "./buckets/registry.js";
19
+ import {
20
+ buildBucketRegistry,
21
+ collectBucketReactionJourneys,
22
+ } from "./buckets/registry.js";
19
23
  import { env } from "./env.js";
20
24
  import { setClientScheduleDefaults } from "./journeys/client-defaults-singleton.js";
21
25
  import type { DefinedJourney } from "./journeys/define-journey.js";
@@ -32,6 +36,8 @@ import { createLogger, type Logger } from "./lib/logger.js";
32
36
  import { createTrackedMailer } from "./lib/mailer.js";
33
37
  import { getPostHog } from "./lib/posthog.js";
34
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";
35
41
 
36
42
  export interface HogsendDefaults {
37
43
  /** Global fallback IANA timezone for scheduling. Defaults to "UTC". */
@@ -66,6 +72,14 @@ export interface HogsendClient {
66
72
  * Empty when no buckets are wired.
67
73
  */
68
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;
69
83
  hatchet: HatchetClient;
70
84
  /**
71
85
  * The client repo's migration journal (`migrations/meta/_journal.json`),
@@ -87,6 +101,13 @@ export interface HogsendClientOptions {
87
101
  journeys?: DefinedJourney[];
88
102
  /** Buckets to register in the {@link BucketRegistry}. Defaults to none. */
89
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[];
90
111
  /**
91
112
  * Email is a first-class channel. Its config is grouped here rather than
92
113
  * spread across top-level args — the engine owns the cohesive email pipeline
@@ -127,6 +148,11 @@ export interface HogsendClientOptions {
127
148
  * `env.ENABLED_BUCKETS`.
128
149
  */
129
150
  enabledBuckets?: string;
151
+ /**
152
+ * Comma-separated ids (or `*`) controlling which lists load. Defaults to
153
+ * `env.ENABLED_LISTS`.
154
+ */
155
+ enabledLists?: string;
130
156
  /**
131
157
  * The client repo's migration journal for the `schema.client` health block.
132
158
  * Defaults to `{ entries: [] }` (empty client track ⇒ trivially in sync).
@@ -200,9 +226,46 @@ export function createHogsendClient(
200
226
  // Installs the bucket registry singleton in BOTH the API and worker processes
201
227
  // (both call createHogsendClient); the real-time ingest path reads it via
202
228
  // getBucketRegistrySingleton().
203
- const bucketRegistry = buildBucketRegistry(
204
- opts.buckets ?? [],
205
- opts.enabledBuckets ?? env.ENABLED_BUCKETS,
229
+ const buckets = opts.buckets ?? [];
230
+ const enabledBuckets = opts.enabledBuckets ?? env.ENABLED_BUCKETS;
231
+ const bucketRegistry = buildBucketRegistry(buckets, enabledBuckets);
232
+
233
+ // Register the reaction journeys generated by `bucket.on()` into the journey
234
+ // registry AFTER buildJourneyRegistry, bypassing the ENABLED_JOURNEYS filter:
235
+ // reactions are bucket-owned and were already gated by ENABLED_BUCKETS
236
+ // (collectBucketReactionJourneys), so their `bucket-<id>-on-<kind>` ids must NOT
237
+ // be subject to the journeys csv (Section 9). Both API and worker call
238
+ // createHogsendClient, so the singleton carries reaction metas in both
239
+ // processes (needed for admin feedsJourneys + the dwell-cron lookup).
240
+ for (const reaction of collectBucketReactionJourneys(
241
+ buckets,
242
+ enabledBuckets,
243
+ )) {
244
+ registry.register(reaction.meta);
245
+ }
246
+
247
+ // Re-bind the member-access accessors on each enabled bucket to THIS
248
+ // container's db so `overrides.db` flows through (the accessors default to the
249
+ // getDb() singleton at defineBucket time, before any container exists —
250
+ // bucket-access.ts dbResolver seam). The enabled set mirrors
251
+ // buildBucketRegistry's filter.
252
+ const enabledIds = new Set(bucketRegistry.getAll().map((b) => b.id));
253
+ for (const bucket of buckets) {
254
+ if (!enabledIds.has(bucket.meta.id)) continue;
255
+ const accessor = createBucketAccessor(bucket.meta.id, () => db);
256
+ bucket.count = accessor.count;
257
+ bucket.has = accessor.has;
258
+ bucket.members = accessor.members;
259
+ bucket.membersIterator = accessor.membersIterator;
260
+ }
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,
206
269
  );
207
270
 
208
271
  const provider =
@@ -261,6 +324,7 @@ export function createHogsendClient(
261
324
  // keep these at debug for non-boot contexts (tests, REPL, library use).
262
325
  logger.debug(`Journey registry loaded: ${registry.count()} journeys`);
263
326
  logger.debug(`Bucket registry loaded: ${bucketRegistry.count()} buckets`);
327
+ logger.debug(`List registry loaded: ${listRegistry.count()} lists`);
264
328
 
265
329
  return {
266
330
  env,
@@ -274,6 +338,7 @@ export function createHogsendClient(
274
338
  analytics,
275
339
  registry,
276
340
  bucketRegistry,
341
+ listRegistry,
277
342
  hatchet: opts.overrides?.hatchet ?? hatchet,
278
343
  clientJournal: opts.clientJournal ?? { entries: [] },
279
344
  defaults,
package/src/env.ts CHANGED
@@ -65,6 +65,10 @@ 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 * * * *"),
70
74
  },
package/src/index.ts CHANGED
@@ -39,6 +39,18 @@ export {
39
39
  // --- App / container / worker factories ---
40
40
  export { type AppEnv, type CreateAppOptions, createApp } from "./app.js";
41
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";
42
54
  export {
43
55
  type BucketTransition,
44
56
  type BucketTransitionKind,
@@ -50,6 +62,8 @@ export {
50
62
  } from "./buckets/define-bucket.js";
51
63
  export {
52
64
  buildBucketRegistry,
65
+ collectBucketReactionJourneys,
66
+ selectBucketReactionTasks,
53
67
  selectBucketTasks,
54
68
  } from "./buckets/registry.js";
55
69
  export {
@@ -161,6 +175,18 @@ export {
161
175
  pushTrackingEvent,
162
176
  resolveEmailSendContext,
163
177
  } from "./lib/tracking-events.js";
178
+ // --- Lists (D3) ---
179
+ export {
180
+ type DefinedList,
181
+ defineList,
182
+ type ListMeta,
183
+ } from "./lists/define-list.js";
184
+ export { buildListRegistry, ListRegistry } from "./lists/registry.js";
185
+ export {
186
+ getListRegistry,
187
+ resetListRegistry,
188
+ setListRegistry,
189
+ } from "./lists/registry-singleton.js";
164
190
  // --- Webhook sources ---
165
191
  export {
166
192
  type DefinedWebhookSource,
@@ -186,5 +212,6 @@ export {
186
212
  } from "./workflows/bucket-reconcile.js";
187
213
  export { checkAlertsTask } from "./workflows/check-alerts.js";
188
214
  export { importContactsTask } from "./workflows/import-contacts.js";
215
+ export { sendCampaignTask } from "./workflows/send-campaign.js";
189
216
  // --- Built-in Hatchet workflow tasks ---
190
217
  export { sendEmailTask } from "./workflows/send-email.js";
@@ -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
@@ -125,12 +125,20 @@ export interface WorkerReadyInfo {
125
125
  client: HogsendClient;
126
126
  journeyTasks: number;
127
127
  bucketTasks: number;
128
+ /** Reaction journey tasks generated by `bucket.on()` (Section 9). */
129
+ bucketReactionTasks: number;
128
130
  builtinTasks: number;
129
131
  }
130
132
 
131
133
  /** Render the worker "ready" output (banner in dev TTY, structured log otherwise). */
132
134
  export function reportWorkerReady(info: WorkerReadyInfo): void {
133
- const { client, journeyTasks, bucketTasks, builtinTasks } = info;
135
+ const {
136
+ client,
137
+ journeyTasks,
138
+ bucketTasks,
139
+ bucketReactionTasks,
140
+ builtinTasks,
141
+ } = info;
134
142
  const engineVersion = getEngineVersion();
135
143
  const hatchetHost = client.env.HATCHET_CLIENT_HOST_PORT;
136
144
 
@@ -141,6 +149,7 @@ export function reportWorkerReady(info: WorkerReadyInfo): void {
141
149
  namespace: client.env.HATCHET_CLIENT_NAMESPACE || undefined,
142
150
  journeyTasks,
143
151
  bucketTasks,
152
+ bucketReactionTasks,
144
153
  builtinTasks,
145
154
  });
146
155
  return;
@@ -151,6 +160,7 @@ export function reportWorkerReady(info: WorkerReadyInfo): void {
151
160
  const tasks = [
152
161
  plural(journeyTasks, "journey task"),
153
162
  plural(bucketTasks, "bucket task"),
163
+ plural(bucketReactionTasks, "reaction task"),
154
164
  plural(builtinTasks, "built-in task"),
155
165
  ].join(dim(" · "));
156
166
 
@@ -161,6 +171,6 @@ export function reportWorkerReady(info: WorkerReadyInfo): void {
161
171
  ` ${ok} ${tasks}`,
162
172
  "",
163
173
  ` ${dim("Listening — journeys fire as events arrive.")}`,
164
- ` ${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")}`,
165
175
  ]);
166
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
- properties,
85
- idempotencyKey: `bucket:${bucket.id}:${userId}:${kind}:${epoch}`,
126
+ eventProperties: properties,
127
+ idempotencyKey,
86
128
  },
87
129
  });
88
130
 
@@ -99,7 +141,7 @@ export async function emitBucketTransition(opts: {
99
141
  event: genericEvent,
100
142
  userId,
101
143
  userEmail: userEmail ?? "",
102
- properties,
144
+ eventProperties: properties,
103
145
  idempotencyKey: `bucket:${bucket.id}:${userId}:${kind}:${epoch}:generic`,
104
146
  },
105
147
  });