@hogsend/engine 0.6.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/check-membership.ts +34 -15
- package/src/container.ts +33 -0
- package/src/env.ts +4 -0
- package/src/index.ts +13 -0
- package/src/journeys/journey-context.ts +5 -1
- package/src/lib/boot.ts +1 -1
- package/src/lib/bucket-emit.ts +2 -2
- 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/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 +6 -0
- package/src/workflows/bucket-backfill.ts +32 -21
- package/src/workflows/bucket-reconcile.ts +20 -5
- package/src/workflows/import-contacts.ts +28 -20
- package/src/workflows/send-campaign.ts +589 -0
- 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.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -37,11 +37,11 @@
|
|
|
37
37
|
"resend": "^6.12.3",
|
|
38
38
|
"winston": "^3.19.0",
|
|
39
39
|
"zod": "^4.4.3",
|
|
40
|
-
"@hogsend/core": "^0.
|
|
41
|
-
"@hogsend/db": "^0.
|
|
42
|
-
"@hogsend/email": "^0.
|
|
43
|
-
"@hogsend/plugin-posthog": "^0.
|
|
44
|
-
"@hogsend/plugin-resend": "^0.
|
|
40
|
+
"@hogsend/core": "^0.7.0",
|
|
41
|
+
"@hogsend/db": "^0.7.0",
|
|
42
|
+
"@hogsend/email": "^0.7.0",
|
|
43
|
+
"@hogsend/plugin-posthog": "^0.7.0",
|
|
44
|
+
"@hogsend/plugin-resend": "^0.7.0"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@types/node": "^22.15.3",
|
|
@@ -51,7 +51,16 @@ export async function checkBucketMembership(opts: {
|
|
|
51
51
|
userId: string;
|
|
52
52
|
userEmail: string | null;
|
|
53
53
|
event: string;
|
|
54
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
113
|
-
// event payload (Section 6.1 rule #3
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
...
|
|
150
|
-
...(
|
|
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,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
|
@@ -175,6 +175,18 @@ export {
|
|
|
175
175
|
pushTrackingEvent,
|
|
176
176
|
resolveEmailSendContext,
|
|
177
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";
|
|
178
190
|
// --- Webhook sources ---
|
|
179
191
|
export {
|
|
180
192
|
type DefinedWebhookSource,
|
|
@@ -200,5 +212,6 @@ export {
|
|
|
200
212
|
} from "./workflows/bucket-reconcile.js";
|
|
201
213
|
export { checkAlertsTask } from "./workflows/check-alerts.js";
|
|
202
214
|
export { importContactsTask } from "./workflows/import-contacts.js";
|
|
215
|
+
export { sendCampaignTask } from "./workflows/send-campaign.js";
|
|
203
216
|
// --- Built-in Hatchet workflow tasks ---
|
|
204
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
|
@@ -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/
|
|
174
|
+
` ${dim("Send one:")} ${color.cyan("POST /v1/events")} ${dim("· or Studio › Debug")}`,
|
|
175
175
|
]);
|
|
176
176
|
}
|
package/src/lib/bucket-emit.ts
CHANGED
|
@@ -123,7 +123,7 @@ export async function emitBucketTransition(opts: {
|
|
|
123
123
|
event: eventName,
|
|
124
124
|
userId,
|
|
125
125
|
userEmail: userEmail ?? "",
|
|
126
|
-
properties,
|
|
126
|
+
eventProperties: properties,
|
|
127
127
|
idempotencyKey,
|
|
128
128
|
},
|
|
129
129
|
});
|
|
@@ -141,7 +141,7 @@ export async function emitBucketTransition(opts: {
|
|
|
141
141
|
event: genericEvent,
|
|
142
142
|
userId,
|
|
143
143
|
userEmail: userEmail ?? "",
|
|
144
|
-
properties,
|
|
144
|
+
eventProperties: properties,
|
|
145
145
|
idempotencyKey: `bucket:${bucket.id}:${userId}:${kind}:${epoch}:generic`,
|
|
146
146
|
},
|
|
147
147
|
});
|