@hogsend/engine 0.17.0 → 0.18.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/engine",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -40,14 +40,14 @@
40
40
  "svix": "^1.95.1",
41
41
  "winston": "^3.19.0",
42
42
  "zod": "^4.4.3",
43
- "@hogsend/core": "^0.17.0",
44
- "@hogsend/db": "^0.17.0",
45
- "@hogsend/email": "^0.17.0",
46
- "@hogsend/plugin-posthog": "^0.17.0",
47
- "@hogsend/plugin-resend": "^0.17.0"
43
+ "@hogsend/core": "^0.18.0",
44
+ "@hogsend/db": "^0.18.0",
45
+ "@hogsend/email": "^0.18.0",
46
+ "@hogsend/plugin-posthog": "^0.18.0",
47
+ "@hogsend/plugin-resend": "^0.18.0"
48
48
  },
49
49
  "optionalDependencies": {
50
- "@hogsend/plugin-postmark": "^0.17.0"
50
+ "@hogsend/plugin-postmark": "^0.18.0"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/node": "^22.15.3",
@@ -122,6 +122,10 @@ interface ResolveKey {
122
122
  value: string;
123
123
  }
124
124
 
125
+ /** Postgres uuid syntax — guards the `contacts.id` fallback cast below. */
126
+ const UUID_PATTERN =
127
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
128
+
125
129
  /**
126
130
  * Look up the single live contact owning `(kind, value)`, falling back to
127
131
  * `contact_aliases` on a miss so a stale (loser/promoted) key still resolves to
@@ -153,14 +157,34 @@ async function findByKey(tx: Tx, key: ResolveKey): Promise<ContactRow | null> {
153
157
  ),
154
158
  )
155
159
  .limit(1);
156
- if (!alias[0]) return null;
160
+ if (alias[0]) {
161
+ const aliased = await tx
162
+ .select()
163
+ .from(contacts)
164
+ .where(
165
+ and(eq(contacts.id, alias[0].contactId), isNull(contacts.deletedAt)),
166
+ )
167
+ .limit(1);
168
+ if (aliased[0]) return aliased[0];
169
+ }
157
170
 
158
- const aliased = await tx
159
- .select()
160
- .from(contacts)
161
- .where(and(eq(contacts.id, alias[0].contactId), isNull(contacts.deletedAt)))
162
- .limit(1);
163
- return aliased[0] ?? null;
171
+ // Row-id fallback (external keys only): an email-only / anonymous-only
172
+ // contact's canonical key (`external_id ?? anonymous_id ?? id`) IS its row id,
173
+ // and that key leaves the system — in Hatchet event payloads, outbound
174
+ // destination `userId`s, and `hs_t` identity tokens. When such a key round-trips
175
+ // back through ingest as a `userId` (e.g. a PostHog webhook forwarding events
176
+ // for a person identified via the `hs_t` stitch), it must resolve to the SAME
177
+ // contact, not mint a duplicate keyed by the old row's id.
178
+ if (key.kind === "external" && UUID_PATTERN.test(key.value)) {
179
+ const byId = await tx
180
+ .select()
181
+ .from(contacts)
182
+ .where(and(eq(contacts.id, key.value), isNull(contacts.deletedAt)))
183
+ .limit(1);
184
+ return byId[0] ?? null;
185
+ }
186
+
187
+ return null;
164
188
  }
165
189
 
166
190
  /**
@@ -929,6 +953,20 @@ async function recordMergeAliases(
929
953
  reason: "merge",
930
954
  });
931
955
  }
956
+ // When the loser had neither external_id nor anonymous_id, its CANONICAL key
957
+ // (`external_id ?? anonymous_id ?? id`) was its row id — and that key has
958
+ // circulated (Hatchet payloads, outbound `userId`s, `hs_t` tokens). Alias it
959
+ // as an external key so a round-trip still resolves to the survivor after the
960
+ // soft-delete takes the row out of findByKey's id fallback.
961
+ if (!loser.externalId && !loser.anonymousId) {
962
+ aliasRows.push({
963
+ contactId: survivorId,
964
+ aliasKind: "external",
965
+ aliasValue: loser.id,
966
+ fromContactId: loser.id,
967
+ reason: "merge",
968
+ });
969
+ }
932
970
 
933
971
  if (aliasRows.length === 0) return;
934
972
 
@@ -36,6 +36,15 @@ export interface ExitResult {
36
36
  export interface IngestResult {
37
37
  stored: boolean;
38
38
  exits: ExitResult[];
39
+ /**
40
+ * The contact's canonical text key after this ingest's identity resolve
41
+ * (`external_id ?? anonymous_id ?? id`). This is the same key outbound
42
+ * destinations emit as `userId` and `hs_t` identity tokens carry — callers
43
+ * (e.g. a site's subscribe endpoint) can hand it to their analytics
44
+ * `identify()` so the session joins the person the contact's email events
45
+ * land on, without any PII leaving Hogsend.
46
+ */
47
+ contactKey: string;
39
48
  }
40
49
 
41
50
  export async function ingestEvent(opts: {
@@ -85,7 +94,7 @@ export async function ingestEvent(opts: {
85
94
  .returning({ id: userEvents.id });
86
95
 
87
96
  if (result.length === 0) {
88
- return { stored: false, exits: [] };
97
+ return { stored: false, exits: [], contactKey: resolvedKey };
89
98
  }
90
99
  idempotentInsertId = result[0]?.id;
91
100
  } else {
@@ -185,7 +194,7 @@ export async function ingestEvent(opts: {
185
194
  exits: exits.filter((e) => e.exited).length,
186
195
  });
187
196
 
188
- return { stored: true, exits };
197
+ return { stored: true, exits, contactKey: resolvedKey };
189
198
  }
190
199
 
191
200
  async function checkExits(
package/src/lib/studio.ts CHANGED
@@ -88,7 +88,13 @@ export function mountStudio(app: OpenAPIHono<AppEnv>): MountStudioResult {
88
88
  });
89
89
 
90
90
  // Redirect the bare `/studio` to `/studio/` so relative/base assets resolve.
91
- app.get("/studio", (c) => c.redirect("/studio/"));
91
+ // The query string MUST survive the hop: better-auth's password-reset link
92
+ // redirects to `/studio?token=…`, and dropping the token here strands the
93
+ // user on the login card instead of the reset form.
94
+ app.get("/studio", (c) => {
95
+ const { search } = new URL(c.req.url);
96
+ return c.redirect(`/studio/${search}`);
97
+ });
92
98
 
93
99
  // Static assets (js/css/images) under /studio/*.
94
100
  app.use("/studio/*", staticHandler);
@@ -25,6 +25,11 @@ const eventResponseSchema = z.object({
25
25
  exited: z.boolean(),
26
26
  }),
27
27
  ),
28
+ // The contact's canonical key (`external_id ?? anonymous_id ?? id`) — the
29
+ // same key outbound destinations and `hs_t` identity tokens carry, so the
30
+ // caller can `identify()` its analytics session against the contact without
31
+ // any PII round-trip.
32
+ contactKey: z.string(),
28
33
  // Present only when the event was durably ingested but the (non-atomic,
29
34
  // post-ingest) list-membership write failed. The ingest itself succeeded —
30
35
  // surfaced as a warning on a 202, not a 400 that conflates "nothing happened"