@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 +7 -7
- package/src/lib/contacts.ts +45 -7
- package/src/lib/ingestion.ts +11 -2
- package/src/lib/studio.ts +7 -1
- package/src/routes/events/index.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/engine",
|
|
3
|
-
"version": "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.
|
|
44
|
-
"@hogsend/db": "^0.
|
|
45
|
-
"@hogsend/email": "^0.
|
|
46
|
-
"@hogsend/plugin-posthog": "^0.
|
|
47
|
-
"@hogsend/plugin-resend": "^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.
|
|
50
|
+
"@hogsend/plugin-postmark": "^0.18.0"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@types/node": "^22.15.3",
|
package/src/lib/contacts.ts
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
package/src/lib/ingestion.ts
CHANGED
|
@@ -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
|
-
|
|
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"
|