@hogsend/engine 0.21.1 → 0.23.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/app.ts +37 -30
- package/src/connectors/define-connector.ts +205 -0
- package/src/connectors/presets/index.ts +31 -0
- package/src/connectors/registry-singleton.ts +79 -0
- package/src/container.ts +94 -0
- package/src/env.ts +5 -0
- package/src/index.ts +69 -0
- package/src/lib/analytics-identity.ts +112 -0
- package/src/lib/connector-link-codes.ts +218 -0
- package/src/lib/connector-state.ts +114 -0
- package/src/lib/contacts.ts +233 -26
- package/src/lib/discord-gateway-heartbeat.ts +164 -0
- package/src/lib/identity-service.ts +107 -0
- package/src/lib/identity-token.ts +65 -5
- package/src/lib/ingestion.ts +58 -2
- package/src/lib/outbound.ts +17 -0
- package/src/lib/provider-credentials.ts +11 -0
- package/src/lib/semantic-click.ts +15 -6
- package/src/lib/tracking-events.ts +5 -1
- package/src/lib/tracking.ts +37 -0
- package/src/lib/webhook-signing.ts +7 -1
- package/src/routes/admin/connectors.ts +466 -0
- package/src/routes/admin/index.ts +2 -0
- package/src/routes/connectors/index.ts +279 -0
- package/src/routes/contacts/index.ts +7 -0
- package/src/routes/events/index.ts +16 -1
- package/src/routes/index.ts +17 -4
- package/src/routes/tracking/answer.ts +11 -4
- package/src/routes/tracking/click.ts +130 -71
- package/src/routes/tracking/identify.ts +62 -15
- package/src/routes/webhooks/index.ts +3 -3
- package/src/routes/webhooks/sources.ts +20 -10
- package/src/webhook-sources/define-webhook-source.ts +44 -39
- package/src/webhook-sources/verify.ts +4 -1
package/src/lib/contacts.ts
CHANGED
|
@@ -100,6 +100,7 @@ export function contactSearchFilter(search: string) {
|
|
|
100
100
|
ilike(contacts.email, `%${search}%`),
|
|
101
101
|
ilike(contacts.externalId, `%${search}%`),
|
|
102
102
|
ilike(contacts.anonymousId, `%${search}%`),
|
|
103
|
+
ilike(contacts.discordId, `%${search}%`),
|
|
103
104
|
);
|
|
104
105
|
}
|
|
105
106
|
|
|
@@ -115,7 +116,7 @@ export function normalizeEmail(raw: string): string {
|
|
|
115
116
|
// Identity resolution
|
|
116
117
|
// ---------------------------------------------------------------------------
|
|
117
118
|
|
|
118
|
-
type Kind = "external" | "email" | "anonymous";
|
|
119
|
+
type Kind = "external" | "email" | "anonymous" | "discord";
|
|
119
120
|
|
|
120
121
|
interface ResolveKey {
|
|
121
122
|
kind: Kind;
|
|
@@ -137,7 +138,9 @@ async function findByKey(tx: Tx, key: ResolveKey): Promise<ContactRow | null> {
|
|
|
137
138
|
? contacts.externalId
|
|
138
139
|
: key.kind === "email"
|
|
139
140
|
? contacts.email
|
|
140
|
-
:
|
|
141
|
+
: key.kind === "anonymous"
|
|
142
|
+
? contacts.anonymousId
|
|
143
|
+
: contacts.discordId;
|
|
141
144
|
|
|
142
145
|
const direct = await tx
|
|
143
146
|
.select()
|
|
@@ -187,6 +190,18 @@ async function findByKey(tx: Tx, key: ResolveKey): Promise<ContactRow | null> {
|
|
|
187
190
|
return null;
|
|
188
191
|
}
|
|
189
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Top-level property keys whose object value is DEEP-merged (one level) rather
|
|
195
|
+
* than wholly replaced. The §2.1 shallow `||` contract clobbers a top-level key
|
|
196
|
+
* outright, so a nested metadata object (e.g. the Discord connector's
|
|
197
|
+
* `properties.discord`) would lose every field the current event doesn't carry
|
|
198
|
+
* (a reaction knows `last_seen` but not `username`, so it would erase a
|
|
199
|
+
* previously-captured `username`). Listing the key here makes ONLY that key
|
|
200
|
+
* additive — siblings stay strictly shallow, preserving the documented contract
|
|
201
|
+
* for everything else. NON-KEY metadata only; never an identity-resolution key.
|
|
202
|
+
*/
|
|
203
|
+
const DEEP_MERGE_KEYS = ["discord"] as const;
|
|
204
|
+
|
|
190
205
|
/**
|
|
191
206
|
* Merge `patch` onto the existing jsonb properties (§2.1 contract): additive
|
|
192
207
|
* `COALESCE(existing,'{}') || patch` where the patch wins on key conflict AND an
|
|
@@ -197,9 +212,56 @@ async function findByKey(tx: Tx, key: ResolveKey): Promise<ContactRow | null> {
|
|
|
197
212
|
* Caveat: `jsonb_strip_nulls` also strips any PRE-EXISTING null-valued keys on
|
|
198
213
|
* the contact, which is the intended "null === unset" model (the condition
|
|
199
214
|
* engine already treats JSON null and absent identically).
|
|
215
|
+
*
|
|
216
|
+
* EXCEPTION — keys in {@link DEEP_MERGE_KEYS} that carry an object value are
|
|
217
|
+
* merged ONE level deep: `existing.discord || patch.discord` instead of the
|
|
218
|
+
* top-level `||` replacing `discord` wholesale. Postgres has no recursive `||`,
|
|
219
|
+
* so we build the deep-merged sub-object explicitly and overlay it last. A
|
|
220
|
+
* non-object value for such a key (or an absent one) falls through to the normal
|
|
221
|
+
* shallow merge untouched.
|
|
200
222
|
*/
|
|
201
223
|
function mergePropertiesSql(patch: Record<string, unknown>) {
|
|
202
|
-
|
|
224
|
+
let merged = sql`COALESCE(${contacts.properties}, '{}'::jsonb) || ${JSON.stringify(patch)}::jsonb`;
|
|
225
|
+
for (const key of DEEP_MERGE_KEYS) {
|
|
226
|
+
const sub = patch[key];
|
|
227
|
+
if (sub && typeof sub === "object" && !Array.isArray(sub)) {
|
|
228
|
+
// existing[key] (already an object or absent) || patch[key] — the prior
|
|
229
|
+
// sub-object's fields survive any field the current patch omits.
|
|
230
|
+
// `${key}` is cast to ::text: jsonb_build_object is VARIADIC "any" and `->`
|
|
231
|
+
// is overloaded (text key vs int index), so an untyped bound parameter
|
|
232
|
+
// can't have its type inferred ("could not determine data type of $n").
|
|
233
|
+
merged = sql`${merged} || jsonb_build_object(${key}::text, COALESCE(${contacts.properties} -> ${key}::text, '{}'::jsonb) || ${JSON.stringify(sub)}::jsonb)`;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return sql`jsonb_strip_nulls(${merged})`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
240
|
+
return Boolean(v) && typeof v === "object" && !Array.isArray(v);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Spread-merge one `layer` onto the accumulated `acc` (incoming wins per key),
|
|
245
|
+
* the in-memory analogue of {@link mergePropertiesSql}'s deep-merge exception
|
|
246
|
+
* for the collide-MERGE fold (which folds properties via JS spread, not SQL).
|
|
247
|
+
* For each {@link DEEP_MERGE_KEYS} key that is an object on BOTH `acc` and the
|
|
248
|
+
* incoming `layer`, the sub-objects are themselves shallow-merged (incoming
|
|
249
|
+
* wins per sub-key) so the layer can't clobber fields the accumulator already
|
|
250
|
+
* holds — must read the PRE-spread `acc` value, hence a fresh result object.
|
|
251
|
+
*/
|
|
252
|
+
function foldLayer(
|
|
253
|
+
acc: Record<string, unknown>,
|
|
254
|
+
layer: Record<string, unknown>,
|
|
255
|
+
): Record<string, unknown> {
|
|
256
|
+
const out: Record<string, unknown> = { ...acc, ...layer };
|
|
257
|
+
for (const key of DEEP_MERGE_KEYS) {
|
|
258
|
+
const a = acc[key];
|
|
259
|
+
const b = layer[key];
|
|
260
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
261
|
+
out[key] = { ...a, ...b };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return out;
|
|
203
265
|
}
|
|
204
266
|
|
|
205
267
|
/**
|
|
@@ -277,6 +339,7 @@ export async function resolveOrCreateContact(opts: {
|
|
|
277
339
|
userId?: string;
|
|
278
340
|
email?: string;
|
|
279
341
|
anonymousId?: string;
|
|
342
|
+
discordId?: string;
|
|
280
343
|
contactProperties?: Record<string, unknown>;
|
|
281
344
|
}): Promise<{
|
|
282
345
|
id: string;
|
|
@@ -290,20 +353,40 @@ export async function resolveOrCreateContact(opts: {
|
|
|
290
353
|
created: boolean;
|
|
291
354
|
linked: boolean;
|
|
292
355
|
merged: boolean;
|
|
356
|
+
/**
|
|
357
|
+
* SAFE-to-absorb loser keys (§5.3 MF-2): the anonymous/uuid keys the resolver
|
|
358
|
+
* folded INTO `resolvedKey` this call — populated only on a collide-MERGE or a
|
|
359
|
+
* canonical-key flip that absorbed an anon/uuid key. Callers fan these out via
|
|
360
|
+
* `mergeAnalyticsIdentities({ distinctId: resolvedKey, alias: <key> })`. An
|
|
361
|
+
* `external_id` is NEVER listed here (it carried an identified PostHog person;
|
|
362
|
+
* aliasing it is the merge PostHog refuses — R2/R4); it surfaces in
|
|
363
|
+
* {@link mergedIdentifiedKeys} instead. Empty/absent ⇒ nothing to stitch.
|
|
364
|
+
*/
|
|
365
|
+
mergedKeys?: string[];
|
|
366
|
+
/**
|
|
367
|
+
* Loser keys MF-2 could NOT safely absorb — already-identified `external_id`s
|
|
368
|
+
* (and the superseded `external_id` on a key flip). These are the known
|
|
369
|
+
* steady-state twin residual (§10, OQ-1); callers log them as
|
|
370
|
+
* `identity.merge.residual_twin` for observability. Never aliased.
|
|
371
|
+
*/
|
|
372
|
+
mergedIdentifiedKeys?: string[];
|
|
293
373
|
}> {
|
|
294
374
|
const { db, contactProperties } = opts;
|
|
295
375
|
const userId = opts.userId?.trim() || undefined;
|
|
296
376
|
const email = opts.email ? normalizeEmail(opts.email) : undefined;
|
|
297
377
|
const anonymousId = opts.anonymousId?.trim() || undefined;
|
|
378
|
+
const discordId = opts.discordId?.trim() || undefined;
|
|
298
379
|
|
|
299
380
|
const keys: ResolveKey[] = [];
|
|
300
381
|
if (userId) keys.push({ kind: "external", value: userId });
|
|
301
382
|
if (email) keys.push({ kind: "email", value: email });
|
|
302
383
|
if (anonymousId) keys.push({ kind: "anonymous", value: anonymousId });
|
|
384
|
+
if (discordId) keys.push({ kind: "discord", value: discordId });
|
|
303
385
|
|
|
304
386
|
if (keys.length === 0) {
|
|
305
387
|
throw new Error(
|
|
306
|
-
"resolveOrCreateContact requires at least one of userId, email,
|
|
388
|
+
"resolveOrCreateContact requires at least one of userId, email, " +
|
|
389
|
+
"anonymousId, discordId",
|
|
307
390
|
);
|
|
308
391
|
}
|
|
309
392
|
|
|
@@ -338,6 +421,7 @@ export async function resolveOrCreateContact(opts: {
|
|
|
338
421
|
externalId: userId ?? null,
|
|
339
422
|
email: email ?? null,
|
|
340
423
|
anonymousId: anonymousId ?? null,
|
|
424
|
+
discordId: discordId ?? null,
|
|
341
425
|
// §2.1: explicit null clears a key — never persist a null-valued prop.
|
|
342
426
|
properties: stripNulls(patch),
|
|
343
427
|
})
|
|
@@ -356,25 +440,45 @@ export async function resolveOrCreateContact(opts: {
|
|
|
356
440
|
// --- CASE: fill-in-link (single existing row) ---
|
|
357
441
|
const single = candidates[0];
|
|
358
442
|
if (candidates.length === 1 && single) {
|
|
359
|
-
const { id, resolvedKey } =
|
|
443
|
+
const { id, resolvedKey, mergedKeys, mergedIdentifiedKeys } =
|
|
444
|
+
await fillInLink(tx, single, {
|
|
445
|
+
userId,
|
|
446
|
+
email,
|
|
447
|
+
anonymousId,
|
|
448
|
+
discordId,
|
|
449
|
+
patch,
|
|
450
|
+
hasPatch,
|
|
451
|
+
});
|
|
452
|
+
return {
|
|
453
|
+
id,
|
|
454
|
+
resolvedKey,
|
|
455
|
+
created: false,
|
|
456
|
+
linked: true,
|
|
457
|
+
merged: false,
|
|
458
|
+
mergedKeys,
|
|
459
|
+
mergedIdentifiedKeys,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// --- CASE: collide-MERGE (2-3 distinct rows) ---
|
|
464
|
+
const { id, resolvedKey, mergedKeys, mergedIdentifiedKeys } =
|
|
465
|
+
await mergeContacts(tx, candidates, {
|
|
360
466
|
userId,
|
|
361
467
|
email,
|
|
362
468
|
anonymousId,
|
|
469
|
+
discordId,
|
|
363
470
|
patch,
|
|
364
471
|
hasPatch,
|
|
365
472
|
});
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
hasPatch,
|
|
376
|
-
});
|
|
377
|
-
return { id, resolvedKey, created: false, linked: true, merged: true };
|
|
473
|
+
return {
|
|
474
|
+
id,
|
|
475
|
+
resolvedKey,
|
|
476
|
+
created: false,
|
|
477
|
+
linked: true,
|
|
478
|
+
merged: true,
|
|
479
|
+
mergedKeys,
|
|
480
|
+
mergedIdentifiedKeys,
|
|
481
|
+
};
|
|
378
482
|
});
|
|
379
483
|
}
|
|
380
484
|
|
|
@@ -382,6 +486,7 @@ interface ResolveCtx {
|
|
|
382
486
|
userId?: string;
|
|
383
487
|
email?: string;
|
|
384
488
|
anonymousId?: string;
|
|
489
|
+
discordId?: string;
|
|
385
490
|
patch: Record<string, unknown>;
|
|
386
491
|
hasPatch: boolean;
|
|
387
492
|
}
|
|
@@ -395,7 +500,12 @@ async function fillInLink(
|
|
|
395
500
|
tx: Tx,
|
|
396
501
|
row: ContactRow,
|
|
397
502
|
ctx: ResolveCtx,
|
|
398
|
-
): Promise<{
|
|
503
|
+
): Promise<{
|
|
504
|
+
id: string;
|
|
505
|
+
resolvedKey: string;
|
|
506
|
+
mergedKeys?: string[];
|
|
507
|
+
mergedIdentifiedKeys?: string[];
|
|
508
|
+
}> {
|
|
399
509
|
const set: Record<string, unknown> = {
|
|
400
510
|
lastSeenAt: new Date(),
|
|
401
511
|
updatedAt: new Date(),
|
|
@@ -420,6 +530,14 @@ async function fillInLink(
|
|
|
420
530
|
set.email = ctx.email;
|
|
421
531
|
promoted.push({ kind: "email", value: ctx.email });
|
|
422
532
|
}
|
|
533
|
+
// discord_id is an attachable resolvable key but NEVER the canonical key
|
|
534
|
+
// (external_id ?? anonymous_id ?? id), so it does NOT touch
|
|
535
|
+
// nextExternalId/nextAnonymousId — gaining it never flips the canonical key,
|
|
536
|
+
// so no own-history re-point follows.
|
|
537
|
+
if (ctx.discordId && !row.discordId) {
|
|
538
|
+
set.discordId = ctx.discordId;
|
|
539
|
+
promoted.push({ kind: "discord", value: ctx.discordId });
|
|
540
|
+
}
|
|
423
541
|
if (ctx.anonymousId && !row.anonymousId) {
|
|
424
542
|
set.anonymousId = ctx.anonymousId;
|
|
425
543
|
nextAnonymousId = ctx.anonymousId;
|
|
@@ -435,6 +553,15 @@ async function fillInLink(
|
|
|
435
553
|
// updated row (with its new email/keys) is what foldJourneyStates/email_sends
|
|
436
554
|
// denormalize into.
|
|
437
555
|
const newKey = nextExternalId ?? nextAnonymousId ?? row.id;
|
|
556
|
+
// §5.3 emission point 2 (canonical-key flip): when the key flips, the OLD key
|
|
557
|
+
// is folded into the NEW one. MF-3 gate — only emit a merge when `oldKey` was
|
|
558
|
+
// an anonymous/uuid key (never an `external_id` being superseded; that is the
|
|
559
|
+
// twin case, OQ-1). In practice a flip in fillInLink only fires when the row
|
|
560
|
+
// had NO external_id (attaching one never happens to an already-external row),
|
|
561
|
+
// so `oldKey` is structurally always anon/uuid here — the explicit gate guards
|
|
562
|
+
// the invariant regardless.
|
|
563
|
+
let mergedKeys: string[] | undefined;
|
|
564
|
+
let mergedIdentifiedKeys: string[] | undefined;
|
|
438
565
|
if (newKey !== oldKey) {
|
|
439
566
|
const updatedRow: ContactRow = {
|
|
440
567
|
...row,
|
|
@@ -443,6 +570,14 @@ async function fillInLink(
|
|
|
443
570
|
email: (set.email as string | undefined) ?? row.email,
|
|
444
571
|
};
|
|
445
572
|
await repointOwnHistory(tx, oldKey, newKey, updatedRow);
|
|
573
|
+
|
|
574
|
+
const oldKeyWasExternalId =
|
|
575
|
+
row.externalId != null && oldKey === row.externalId;
|
|
576
|
+
if (oldKeyWasExternalId) {
|
|
577
|
+
mergedIdentifiedKeys = [oldKey];
|
|
578
|
+
} else {
|
|
579
|
+
mergedKeys = [oldKey];
|
|
580
|
+
}
|
|
446
581
|
}
|
|
447
582
|
|
|
448
583
|
for (const key of promoted) {
|
|
@@ -462,7 +597,7 @@ async function fillInLink(
|
|
|
462
597
|
|
|
463
598
|
// `newKey` IS the post-fill canonical key (external_id ?? anonymous_id ?? id) —
|
|
464
599
|
// the same value the old read-back derived.
|
|
465
|
-
return { id: row.id, resolvedKey: newKey };
|
|
600
|
+
return { id: row.id, resolvedKey: newKey, mergedKeys, mergedIdentifiedKeys };
|
|
466
601
|
}
|
|
467
602
|
|
|
468
603
|
/**
|
|
@@ -473,10 +608,22 @@ async function mergeContacts(
|
|
|
473
608
|
tx: Tx,
|
|
474
609
|
candidates: ContactRow[],
|
|
475
610
|
ctx: ResolveCtx,
|
|
476
|
-
): Promise<{
|
|
611
|
+
): Promise<{
|
|
612
|
+
id: string;
|
|
613
|
+
resolvedKey: string;
|
|
614
|
+
mergedKeys?: string[];
|
|
615
|
+
mergedIdentifiedKeys?: string[];
|
|
616
|
+
}> {
|
|
477
617
|
const { survivor, losers } = pickSurvivor(candidates);
|
|
478
618
|
const survivorKey = contactKey(survivor);
|
|
479
619
|
|
|
620
|
+
// §5.3 emission point 1 (collide-MERGE) accumulators. MF-2: a loser's
|
|
621
|
+
// anonymous/uuid key is SAFE to absorb (it never identified a PostHog person);
|
|
622
|
+
// a loser's `external_id` is an already-identified person PostHog refuses to
|
|
623
|
+
// merge on the safe path — it is recorded as the twin residual, NEVER aliased.
|
|
624
|
+
const safeLoserKeys: string[] = [];
|
|
625
|
+
const identifiedLoserKeys: string[] = [];
|
|
626
|
+
|
|
480
627
|
for (const loser of losers) {
|
|
481
628
|
const loserStrKeys = [loser.externalId, loser.anonymousId, loser.id].filter(
|
|
482
629
|
(k): k is string => Boolean(k),
|
|
@@ -485,6 +632,19 @@ async function mergeContacts(
|
|
|
485
632
|
// anonymous id (its user_id rows were keyed on contacts.id).
|
|
486
633
|
const loserKeysToRewrite = loserStrKeys;
|
|
487
634
|
|
|
635
|
+
// MF-2 split: the SAFE-to-absorb key is the loser's anonymous/uuid key —
|
|
636
|
+
// `loser.anonymousId`, or `loser.id` ONLY when the loser was never
|
|
637
|
+
// identified (no external_id). When the loser HAS an external_id, that
|
|
638
|
+
// external_id was its canonical key, so its events were captured under it
|
|
639
|
+
// (identified) → residual; `loser.id` never carried events in that case, so
|
|
640
|
+
// there is no safe key to alias from it.
|
|
641
|
+
if (loser.externalId) {
|
|
642
|
+
identifiedLoserKeys.push(loser.externalId);
|
|
643
|
+
if (loser.anonymousId) safeLoserKeys.push(loser.anonymousId);
|
|
644
|
+
} else {
|
|
645
|
+
safeLoserKeys.push(loser.anonymousId ?? loser.id);
|
|
646
|
+
}
|
|
647
|
+
|
|
488
648
|
// (ii) user_events.user_id rewrite.
|
|
489
649
|
await tx
|
|
490
650
|
.update(userEvents)
|
|
@@ -518,14 +678,23 @@ async function mergeContacts(
|
|
|
518
678
|
}
|
|
519
679
|
|
|
520
680
|
// (vii) FOLD properties: survivor wins over losers; then the call's patch wins
|
|
521
|
-
// last. timezone = survivor ?? loser; firstSeenAt = least.
|
|
681
|
+
// last. timezone = survivor ?? loser; firstSeenAt = least. DEEP_MERGE_KEYS
|
|
682
|
+
// (e.g. `discord`) are sub-object-merged at each fold layer (foldLayer) so a
|
|
683
|
+
// loser/survivor/patch that carries only a subset of the nested object's
|
|
684
|
+
// fields doesn't clobber the rest — matching mergePropertiesSql's exception.
|
|
522
685
|
let foldedProps: Record<string, unknown> = {};
|
|
523
686
|
for (const loser of losers) {
|
|
524
|
-
foldedProps =
|
|
687
|
+
foldedProps = foldLayer(
|
|
688
|
+
foldedProps,
|
|
689
|
+
(loser.properties ?? {}) as Record<string, unknown>,
|
|
690
|
+
);
|
|
525
691
|
}
|
|
526
|
-
foldedProps =
|
|
692
|
+
foldedProps = foldLayer(
|
|
693
|
+
foldedProps,
|
|
694
|
+
(survivor.properties ?? {}) as Record<string, unknown>,
|
|
695
|
+
);
|
|
527
696
|
if (ctx.hasPatch) {
|
|
528
|
-
foldedProps =
|
|
697
|
+
foldedProps = foldLayer(foldedProps, ctx.patch);
|
|
529
698
|
}
|
|
530
699
|
// §2.1: an explicit null in the call's patch clears a key — drop null-valued
|
|
531
700
|
// keys from the folded result (matching mergePropertiesSql's strip-nulls).
|
|
@@ -562,6 +731,16 @@ async function mergeContacts(
|
|
|
562
731
|
const next = ctx.anonymousId ?? fromLoser;
|
|
563
732
|
if (next) survivorSet.anonymousId = next;
|
|
564
733
|
}
|
|
734
|
+
// discord_id lands on the survivor (from the call or a loser), but it is
|
|
735
|
+
// NEVER the canonical key — so it is intentionally NOT folded into
|
|
736
|
+
// newSurvivorKey below and a discord-only merge does no history re-point. The
|
|
737
|
+
// losers are soft-deleted FIRST (below) so the partial-unique discord_id index
|
|
738
|
+
// is freed before this copy.
|
|
739
|
+
if (!survivor.discordId) {
|
|
740
|
+
const fromLoser = losers.find((l) => l.discordId)?.discordId;
|
|
741
|
+
const next = ctx.discordId ?? fromLoser;
|
|
742
|
+
if (next) survivorSet.discordId = next;
|
|
743
|
+
}
|
|
565
744
|
|
|
566
745
|
// (viii) Soft-delete the losers FIRST — frees their external_id/email/
|
|
567
746
|
// anonymous_id from the partial-unique indexes (WHERE deleted_at IS NULL) —
|
|
@@ -606,8 +785,16 @@ async function mergeContacts(
|
|
|
606
785
|
}
|
|
607
786
|
|
|
608
787
|
// `newSurvivorKey` IS the post-merge canonical key of the survivor — the same
|
|
609
|
-
// value the old read-back derived for the merged row.
|
|
610
|
-
|
|
788
|
+
// value the old read-back derived for the merged row. The merge folds every
|
|
789
|
+
// loser key into it, so callers fan out `mergeAnalyticsIdentities` aliasing
|
|
790
|
+
// each SAFE loser key into `newSurvivorKey` (§5.3 emission point 1).
|
|
791
|
+
return {
|
|
792
|
+
id: survivor.id,
|
|
793
|
+
resolvedKey: newSurvivorKey,
|
|
794
|
+
mergedKeys: safeLoserKeys.length > 0 ? safeLoserKeys : undefined,
|
|
795
|
+
mergedIdentifiedKeys:
|
|
796
|
+
identifiedLoserKeys.length > 0 ? identifiedLoserKeys : undefined,
|
|
797
|
+
};
|
|
611
798
|
}
|
|
612
799
|
|
|
613
800
|
/**
|
|
@@ -953,6 +1140,20 @@ async function recordMergeAliases(
|
|
|
953
1140
|
reason: "merge",
|
|
954
1141
|
});
|
|
955
1142
|
}
|
|
1143
|
+
// discord_id is a resolvable key, so a stale loser snowflake must still
|
|
1144
|
+
// resolve to the survivor after the soft-delete takes the loser out of
|
|
1145
|
+
// findByKey's direct lookup. Additive — it never conflicts with the
|
|
1146
|
+
// external/anonymous id-fallback alias below (a discord-only loser produces
|
|
1147
|
+
// BOTH this discord alias AND the id→external alias).
|
|
1148
|
+
if (loser.discordId) {
|
|
1149
|
+
aliasRows.push({
|
|
1150
|
+
contactId: survivorId,
|
|
1151
|
+
aliasKind: "discord",
|
|
1152
|
+
aliasValue: loser.discordId,
|
|
1153
|
+
fromContactId: loser.id,
|
|
1154
|
+
reason: "merge",
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
956
1157
|
// When the loser had neither external_id nor anonymous_id, its CANONICAL key
|
|
957
1158
|
// (`external_id ?? anonymous_id ?? id`) was its row id — and that key has
|
|
958
1159
|
// circulated (Hatchet payloads, outbound `userId`s, `hs_t` tokens). Alias it
|
|
@@ -1002,6 +1203,7 @@ export async function upsertContact(opts: {
|
|
|
1002
1203
|
externalId?: string;
|
|
1003
1204
|
email?: string;
|
|
1004
1205
|
anonymousId?: string;
|
|
1206
|
+
discordId?: string;
|
|
1005
1207
|
properties?: Record<string, unknown>;
|
|
1006
1208
|
}): Promise<{
|
|
1007
1209
|
id: string;
|
|
@@ -1009,12 +1211,17 @@ export async function upsertContact(opts: {
|
|
|
1009
1211
|
created: boolean;
|
|
1010
1212
|
linked: boolean;
|
|
1011
1213
|
merged: boolean;
|
|
1214
|
+
/** §5.3 MF-2: safe-to-absorb loser keys folded this call (anon/uuid). */
|
|
1215
|
+
mergedKeys?: string[];
|
|
1216
|
+
/** §5.3 MF-2: already-identified loser keys (twin residual); never aliased. */
|
|
1217
|
+
mergedIdentifiedKeys?: string[];
|
|
1012
1218
|
}> {
|
|
1013
1219
|
return resolveOrCreateContact({
|
|
1014
1220
|
db: opts.db,
|
|
1015
1221
|
userId: opts.externalId,
|
|
1016
1222
|
email: opts.email,
|
|
1017
1223
|
anonymousId: opts.anonymousId,
|
|
1224
|
+
discordId: opts.discordId,
|
|
1018
1225
|
contactProperties: opts.properties,
|
|
1019
1226
|
});
|
|
1020
1227
|
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { Logger } from "./logger.js";
|
|
2
|
+
import { getRedis } from "./redis.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Discord Gateway worker liveness heartbeat. The gateway worker is its OWN
|
|
6
|
+
* long-lived process (a `discord.js` socket forwarding raw dispatches), separate
|
|
7
|
+
* from BOTH the API and the Hatchet worker — so the API (and Studio's
|
|
8
|
+
* `/integrations` card) cannot otherwise tell whether the gateway socket is
|
|
9
|
+
* actually up. The worker writes a TTL'd key to Redis on an interval; readers
|
|
10
|
+
* treat a fresh key as "the gateway worker is alive".
|
|
11
|
+
*
|
|
12
|
+
* This mirrors {@link ./worker-heartbeat.ts} but on a DISTINCT key and with a
|
|
13
|
+
* richer JSON payload: it also carries the guild id the live worker observed at
|
|
14
|
+
* `GUILD_CREATE`, which lets the card confirm "Bot installed" for env-only
|
|
15
|
+
* deploys (no derived credential) — a fresh heartbeat with a guild id IS the
|
|
16
|
+
* proof-of-configuration the status fix needs.
|
|
17
|
+
*
|
|
18
|
+
* Redis is the channel because both processes can already reach it and the
|
|
19
|
+
* health route already probes it — no direct process-to-process coupling, no
|
|
20
|
+
* migration. Everything here is best-effort: a missing/unreachable Redis never
|
|
21
|
+
* crashes the worker and simply reads back as "down".
|
|
22
|
+
*/
|
|
23
|
+
const HEARTBEAT_KEY = "hogsend:discord-gateway:heartbeat";
|
|
24
|
+
const TTL_SECONDS = 30;
|
|
25
|
+
const REFRESH_MS = 10_000;
|
|
26
|
+
|
|
27
|
+
export interface DiscordGatewayHeartbeat {
|
|
28
|
+
/** True when a fresh gateway-worker heartbeat is present in Redis. */
|
|
29
|
+
alive: boolean;
|
|
30
|
+
/** ISO timestamp the gateway worker last wrote, when alive. */
|
|
31
|
+
lastSeenAt?: string;
|
|
32
|
+
/** The guild id the live worker observed (confirms the bot is in a server). */
|
|
33
|
+
guildId?: string;
|
|
34
|
+
/** The intents bitfield the live worker requested at login. */
|
|
35
|
+
intents?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The JSON shape persisted under {@link HEARTBEAT_KEY}. */
|
|
39
|
+
interface HeartbeatPayload {
|
|
40
|
+
lastSeenAt: string;
|
|
41
|
+
guildId?: string;
|
|
42
|
+
intents?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The mutable state a running heartbeat exposes for late-bound folding. */
|
|
46
|
+
export interface DiscordGatewayHeartbeatState {
|
|
47
|
+
/**
|
|
48
|
+
* Fold the worker-observed guild id into the heartbeat and write immediately,
|
|
49
|
+
* so Studio can confirm "Bot installed" as soon as the socket sees a guild.
|
|
50
|
+
*/
|
|
51
|
+
setGuildId(guildId: string): void;
|
|
52
|
+
/**
|
|
53
|
+
* Fold the worker's resolved intents bitfield into the heartbeat and write
|
|
54
|
+
* immediately, so Studio's intents chip reflects what the LIVE worker requested
|
|
55
|
+
* (preferred over the derived credential, which is never written by install).
|
|
56
|
+
*/
|
|
57
|
+
setIntents(intents: number): void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface DiscordGatewayHeartbeatHandle {
|
|
61
|
+
/** Mutable state — call `setGuildId` from the worker's `onGuildObserved` tap. */
|
|
62
|
+
state: DiscordGatewayHeartbeatState;
|
|
63
|
+
/** Clear the timer and delete the key for an immediate "down" on shutdown. */
|
|
64
|
+
stop(): Promise<void>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Begin writing the Discord gateway-worker heartbeat. Writes once immediately,
|
|
69
|
+
* then refreshes every {@link REFRESH_MS} with a {@link TTL_SECONDS} expiry — so
|
|
70
|
+
* an ungraceful worker death is reflected as "down" within the TTL. The returned
|
|
71
|
+
* handle exposes `state.setGuildId(id)` (fold in the observed guild + write now)
|
|
72
|
+
* and `stop()` (clear the timer + delete the key for an immediate "down").
|
|
73
|
+
*/
|
|
74
|
+
export function startDiscordGatewayHeartbeat(
|
|
75
|
+
logger: Logger,
|
|
76
|
+
): DiscordGatewayHeartbeatHandle {
|
|
77
|
+
let warned = false;
|
|
78
|
+
let guildId: string | undefined;
|
|
79
|
+
let intents: number | undefined;
|
|
80
|
+
|
|
81
|
+
const write = async () => {
|
|
82
|
+
const payload: HeartbeatPayload = {
|
|
83
|
+
lastSeenAt: new Date().toISOString(),
|
|
84
|
+
...(guildId ? { guildId } : {}),
|
|
85
|
+
...(typeof intents === "number" ? { intents } : {}),
|
|
86
|
+
};
|
|
87
|
+
try {
|
|
88
|
+
await getRedis().set(
|
|
89
|
+
HEARTBEAT_KEY,
|
|
90
|
+
JSON.stringify(payload),
|
|
91
|
+
"EX",
|
|
92
|
+
TTL_SECONDS,
|
|
93
|
+
);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
// Log the first failure only — a Redis-less deploy would otherwise spam.
|
|
96
|
+
if (!warned) {
|
|
97
|
+
warned = true;
|
|
98
|
+
logger.debug(
|
|
99
|
+
"Discord gateway heartbeat write failed (Redis unreachable?)",
|
|
100
|
+
{ error: err instanceof Error ? err.message : String(err) },
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
void write();
|
|
107
|
+
const timer = setInterval(() => void write(), REFRESH_MS);
|
|
108
|
+
// Never hold the process open for the heartbeat alone.
|
|
109
|
+
timer.unref?.();
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
state: {
|
|
113
|
+
setGuildId(id: string) {
|
|
114
|
+
guildId = id;
|
|
115
|
+
// Write immediately so the card flips to "Bot installed" without waiting
|
|
116
|
+
// for the next refresh tick.
|
|
117
|
+
void write();
|
|
118
|
+
},
|
|
119
|
+
setIntents(value: number) {
|
|
120
|
+
intents = value;
|
|
121
|
+
// Write immediately so the intents chip reflects the live worker without
|
|
122
|
+
// waiting for the next refresh tick.
|
|
123
|
+
void write();
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
async stop() {
|
|
127
|
+
clearInterval(timer);
|
|
128
|
+
try {
|
|
129
|
+
await getRedis().del(HEARTBEAT_KEY);
|
|
130
|
+
} catch {
|
|
131
|
+
// Best-effort — the TTL expires it anyway.
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Read the current Discord gateway-worker heartbeat. Resolves to
|
|
139
|
+
* `{ alive: false }` if the key is missing or Redis is unreachable. Tolerates a
|
|
140
|
+
* legacy plain-string value (treated as alive with no guild) so a reader can
|
|
141
|
+
* outlive a payload-shape change.
|
|
142
|
+
*/
|
|
143
|
+
export async function getDiscordGatewayHeartbeat(): Promise<DiscordGatewayHeartbeat> {
|
|
144
|
+
try {
|
|
145
|
+
const raw = await getRedis().get(HEARTBEAT_KEY);
|
|
146
|
+
if (!raw) return { alive: false };
|
|
147
|
+
try {
|
|
148
|
+
const parsed = JSON.parse(raw) as HeartbeatPayload;
|
|
149
|
+
return {
|
|
150
|
+
alive: true,
|
|
151
|
+
lastSeenAt: parsed.lastSeenAt,
|
|
152
|
+
...(parsed.guildId ? { guildId: parsed.guildId } : {}),
|
|
153
|
+
...(typeof parsed.intents === "number"
|
|
154
|
+
? { intents: parsed.intents }
|
|
155
|
+
: {}),
|
|
156
|
+
};
|
|
157
|
+
} catch {
|
|
158
|
+
// Legacy plain-string value (a bare ISO timestamp) — alive, no guild.
|
|
159
|
+
return { alive: true, lastSeenAt: raw };
|
|
160
|
+
}
|
|
161
|
+
} catch {
|
|
162
|
+
return { alive: false };
|
|
163
|
+
}
|
|
164
|
+
}
|