@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.
Files changed (43) hide show
  1. package/package.json +6 -6
  2. package/src/buckets/check-membership.ts +34 -15
  3. package/src/container.ts +33 -0
  4. package/src/env.ts +4 -0
  5. package/src/index.ts +13 -0
  6. package/src/journeys/journey-context.ts +5 -1
  7. package/src/lib/boot.ts +1 -1
  8. package/src/lib/bucket-emit.ts +2 -2
  9. package/src/lib/contacts.ts +1083 -18
  10. package/src/lib/email-service-types.ts +8 -0
  11. package/src/lib/ingestion.ts +63 -33
  12. package/src/lib/mailer.ts +1 -0
  13. package/src/lib/preferences.ts +106 -0
  14. package/src/lib/tracked.ts +159 -34
  15. package/src/lib/tracking-events.ts +1 -1
  16. package/src/lists/define-list.ts +81 -0
  17. package/src/lists/registry-singleton.ts +39 -0
  18. package/src/lists/registry.ts +95 -0
  19. package/src/middleware/api-key.ts +33 -7
  20. package/src/middleware/rate-limit.ts +73 -49
  21. package/src/routes/_shared.ts +30 -0
  22. package/src/routes/admin/api-keys.ts +1 -1
  23. package/src/routes/admin/bulk.ts +7 -3
  24. package/src/routes/admin/contacts.ts +66 -57
  25. package/src/routes/admin/events.ts +65 -0
  26. package/src/routes/admin/journeys.ts +3 -1
  27. package/src/routes/admin/preferences.ts +2 -2
  28. package/src/routes/admin/reporting.ts +3 -3
  29. package/src/routes/admin/timeline.ts +5 -2
  30. package/src/routes/campaigns/index.ts +252 -0
  31. package/src/routes/contacts/index.ts +188 -0
  32. package/src/routes/email/preferences.ts +27 -3
  33. package/src/routes/email/unsubscribe.ts +7 -49
  34. package/src/routes/emails/index.ts +133 -0
  35. package/src/routes/events/index.ts +119 -0
  36. package/src/routes/index.ts +52 -2
  37. package/src/routes/lists/index.ts +222 -0
  38. package/src/worker.ts +6 -0
  39. package/src/workflows/bucket-backfill.ts +32 -21
  40. package/src/workflows/bucket-reconcile.ts +20 -5
  41. package/src/workflows/import-contacts.ts +28 -20
  42. package/src/workflows/send-campaign.ts +589 -0
  43. 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.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.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"
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
- 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,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
- 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
  }
@@ -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
  });