@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
@@ -41,6 +41,7 @@ import {
41
41
  import { getBucketRegistrySingleton } from "../buckets/registry-singleton.js";
42
42
  import { getJourneyRegistrySingleton } from "../journeys/registry-singleton.js";
43
43
  import { emitBucketTransition } from "../lib/bucket-emit.js";
44
+ import { contactKeySql } from "../lib/contacts.js";
44
45
  import { hatchet } from "../lib/hatchet.js";
45
46
  import type { Logger } from "../lib/logger.js";
46
47
  import { createLogger } from "../lib/logger.js";
@@ -841,18 +842,27 @@ async function reconcileBucketJoins(opts: {
841
842
  ? selectPresentInAllWindows(db, absenceLegs)
842
843
  : null;
843
844
 
845
+ // The membership/event tables key on the RESOLVED string key (external_id ??
846
+ // anonymous_id ?? contact.id), NOT necessarily external_id — email-only /
847
+ // anonymous contacts have a NULL external_id and are keyed on their uuid /
848
+ // anonymous_id. Joining on contacts.externalId would force external_id NOT NULL
849
+ // for every candidate (the coalesce would collapse to external_id) and silently
850
+ // drop exactly the dormant email-only contacts this cron exists to reconcile.
851
+ // Join on the SAME coalesce expression so the projected key matches the join.
852
+ const contactKey = contactKeySql();
853
+
844
854
  const baseQuery = db
845
855
  .select({
846
- userId: contacts.externalId,
856
+ userId: contactKey,
847
857
  email: contacts.email,
848
858
  })
849
859
  .from(contacts)
850
- .innerJoin(everFired, eq(everFired.userId, contacts.externalId))
851
- .leftJoin(activeMembers, eq(activeMembers.userId, contacts.externalId));
860
+ .innerJoin(everFired, eq(everFired.userId, contactKey))
861
+ .leftJoin(activeMembers, eq(activeMembers.userId, contactKey));
852
862
 
853
863
  const candidates = await (presentInAll
854
864
  ? baseQuery
855
- .leftJoin(presentInAll, eq(presentInAll.userId, contacts.externalId))
865
+ .leftJoin(presentInAll, eq(presentInAll.userId, contactKey))
856
866
  .where(
857
867
  and(
858
868
  isNull(contacts.deletedAt),
@@ -864,7 +874,12 @@ async function reconcileBucketJoins(opts: {
864
874
  and(isNull(contacts.deletedAt), isNull(activeMembers.userId)),
865
875
  )
866
876
  )
867
- .orderBy(sql`${contacts.externalId} asc`)
877
+ // Deterministic scan order for the bounded re-run (no keyset cursor; the
878
+ // scan advances as reconciled matchers become active members and drop out).
879
+ // Order by contacts.id (the non-null unique PK) so the scan is null-safe and
880
+ // stable even for null-external_id contacts now that the join is on the
881
+ // coalesce key.
882
+ .orderBy(sql`${contacts.id} asc`)
868
883
  .limit(BATCH_SIZE);
869
884
 
870
885
  // SET-BASED / EXACT shapes (Fix #3) — every candidate row is a true matcher,
@@ -1,11 +1,17 @@
1
1
  import { createDatabase, importJobs } from "@hogsend/db";
2
2
  import { eq } from "drizzle-orm";
3
3
  import Papa from "papaparse";
4
- import { upsertContact } from "../lib/contacts.js";
4
+ import { resolveOrCreateContact } from "../lib/contacts.js";
5
5
  import { hatchet } from "../lib/hatchet.js";
6
6
 
7
7
  const BATCH_SIZE = 500;
8
8
 
9
+ interface ImportRow {
10
+ externalId?: string;
11
+ email?: string;
12
+ properties?: Record<string, unknown>;
13
+ }
14
+
9
15
  export const importContactsTask = hatchet.task({
10
16
  name: "import-contacts",
11
17
  retries: 0,
@@ -20,11 +26,7 @@ export const importContactsTask = hatchet.task({
20
26
  .set({ status: "processing", updatedAt: new Date() })
21
27
  .where(eq(importJobs.id, input.jobId));
22
28
 
23
- let rows: Array<{
24
- externalId: string;
25
- email?: string;
26
- properties?: Record<string, unknown>;
27
- }>;
29
+ let rows: ImportRow[];
28
30
 
29
31
  try {
30
32
  if (input.format === "json") {
@@ -61,14 +63,22 @@ export const importContactsTask = hatchet.task({
61
63
  for (let i = 0; i < rows.length; i += BATCH_SIZE) {
62
64
  const batch = rows.slice(i, i + BATCH_SIZE);
63
65
  const results = await Promise.allSettled(
64
- batch.map((row, idx) =>
65
- upsertContact({
66
+ batch.map((row, idx) => {
67
+ // Accept email-only rows (D1): require AT LEAST one identity key,
68
+ // not externalId specifically. The resolver upserts/merges on
69
+ // whichever keys are present.
70
+ if (!row.externalId && !row.email) {
71
+ return Promise.reject(
72
+ new Error("Row has neither externalId nor email"),
73
+ );
74
+ }
75
+ return resolveOrCreateContact({
66
76
  db,
67
- externalId: row.externalId,
77
+ userId: row.externalId,
68
78
  email: row.email,
69
- properties: row.properties,
70
- }).then(() => ({ index: i + idx, ok: true })),
71
- ),
79
+ contactProperties: row.properties,
80
+ }).then(() => ({ index: i + idx, ok: true }));
81
+ }),
72
82
  );
73
83
 
74
84
  results.forEach((result, batchIdx) => {
@@ -108,25 +118,23 @@ export const importContactsTask = hatchet.task({
108
118
  },
109
119
  });
110
120
 
111
- function parseCsv(data: string): Array<{
112
- externalId: string;
113
- email?: string;
114
- properties?: Record<string, unknown>;
115
- }> {
121
+ function parseCsv(data: string): ImportRow[] {
116
122
  const result = Papa.parse<Record<string, string>>(data, {
117
123
  header: true,
118
124
  skipEmptyLines: true,
119
125
  });
120
126
 
121
- if (!result.meta.fields?.includes("externalId")) {
122
- throw new Error("CSV must have an externalId column");
127
+ const fields = result.meta.fields ?? [];
128
+ // Accept email-only imports (D1): require AT LEAST one identity column.
129
+ if (!fields.includes("externalId") && !fields.includes("email")) {
130
+ throw new Error("CSV must have an externalId or email column");
123
131
  }
124
132
 
125
133
  return result.data.map((row) => {
126
134
  const { externalId, email, ...rest } = row;
127
135
  const properties = Object.keys(rest).length > 0 ? rest : undefined;
128
136
  return {
129
- externalId: externalId ?? "",
137
+ externalId: externalId || undefined,
130
138
  email: email || undefined,
131
139
  properties: properties as Record<string, unknown> | undefined,
132
140
  };