@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.
@@ -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
- : contacts.anonymousId;
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
- return sql`jsonb_strip_nulls(COALESCE(${contacts.properties}, '{}'::jsonb) || ${JSON.stringify(patch)}::jsonb)`;
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, anonymousId",
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 } = await fillInLink(tx, single, {
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
- return { id, resolvedKey, created: false, linked: true, merged: false };
367
- }
368
-
369
- // --- CASE: collide-MERGE (2-3 distinct rows) ---
370
- const { id, resolvedKey } = await mergeContacts(tx, candidates, {
371
- userId,
372
- email,
373
- anonymousId,
374
- patch,
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<{ id: string; resolvedKey: string }> {
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<{ id: string; resolvedKey: string }> {
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 = { ...foldedProps, ...((loser.properties ?? {}) as object) };
687
+ foldedProps = foldLayer(
688
+ foldedProps,
689
+ (loser.properties ?? {}) as Record<string, unknown>,
690
+ );
525
691
  }
526
- foldedProps = { ...foldedProps, ...((survivor.properties ?? {}) as object) };
692
+ foldedProps = foldLayer(
693
+ foldedProps,
694
+ (survivor.properties ?? {}) as Record<string, unknown>,
695
+ );
527
696
  if (ctx.hasPatch) {
528
- foldedProps = { ...foldedProps, ...ctx.patch };
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
- return { id: survivor.id, resolvedKey: newSurvivorKey };
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
+ }