@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.
- package/package.json +6 -6
- package/src/buckets/bucket-access.ts +213 -0
- package/src/buckets/bucket-reactions.ts +225 -0
- package/src/buckets/check-membership.ts +35 -15
- package/src/buckets/define-bucket.ts +79 -8
- package/src/buckets/registry.ts +81 -0
- package/src/container.ts +69 -4
- package/src/env.ts +4 -0
- package/src/index.ts +27 -0
- package/src/journeys/journey-context.ts +5 -1
- package/src/lib/boot.ts +12 -2
- package/src/lib/bucket-emit.ts +49 -7
- package/src/lib/contacts.ts +1083 -18
- package/src/lib/email-service-types.ts +8 -0
- package/src/lib/ingestion.ts +63 -33
- package/src/lib/mailer.ts +1 -0
- package/src/lib/preferences.ts +106 -0
- package/src/lib/tracked.ts +159 -34
- package/src/lib/tracking-events.ts +1 -1
- package/src/lists/define-list.ts +81 -0
- package/src/lists/registry-singleton.ts +39 -0
- package/src/lists/registry.ts +95 -0
- package/src/middleware/api-key.ts +33 -7
- package/src/middleware/rate-limit.ts +73 -49
- package/src/routes/_shared.ts +30 -0
- package/src/routes/admin/api-keys.ts +1 -1
- package/src/routes/admin/buckets.ts +39 -9
- package/src/routes/admin/bulk.ts +7 -3
- package/src/routes/admin/contacts.ts +66 -57
- package/src/routes/admin/events.ts +65 -0
- package/src/routes/admin/journeys.ts +3 -1
- package/src/routes/admin/preferences.ts +2 -2
- package/src/routes/admin/reporting.ts +3 -3
- package/src/routes/admin/timeline.ts +5 -2
- package/src/routes/campaigns/index.ts +252 -0
- package/src/routes/contacts/index.ts +188 -0
- package/src/routes/email/preferences.ts +27 -3
- package/src/routes/email/unsubscribe.ts +7 -49
- package/src/routes/emails/index.ts +133 -0
- package/src/routes/events/index.ts +119 -0
- package/src/routes/index.ts +52 -2
- package/src/routes/lists/index.ts +222 -0
- package/src/worker.ts +25 -2
- package/src/workflows/bucket-backfill.ts +122 -22
- package/src/workflows/bucket-reconcile.ts +225 -12
- package/src/workflows/import-contacts.ts +28 -20
- package/src/workflows/send-campaign.ts +589 -0
- package/src/routes/ingest.ts +0 -71
package/src/buckets/registry.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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 {
|
|
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/
|
|
174
|
+
` ${dim("Send one:")} ${color.cyan("POST /v1/events")} ${dim("· or Studio › Debug")}`,
|
|
165
175
|
]);
|
|
166
176
|
}
|
package/src/lib/bucket-emit.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
123
|
+
event: eventName,
|
|
82
124
|
userId,
|
|
83
125
|
userEmail: userEmail ?? "",
|
|
84
|
-
properties,
|
|
85
|
-
idempotencyKey
|
|
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
|
});
|