@hogsend/engine 0.1.1 → 0.2.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.
@@ -1,4 +1,5 @@
1
1
  import {
2
+ bucketMemberships,
2
3
  contacts,
3
4
  emailPreferences,
4
5
  emailSends,
@@ -33,6 +34,8 @@ const overviewRoute = createRoute({
33
34
  schema: z.object({
34
35
  totalContacts: z.number(),
35
36
  activeJourneys: z.number(),
37
+ activeBuckets: z.number(),
38
+ bucketMembers: z.number(),
36
39
  emailsSent24h: z.number(),
37
40
  emailsSent7d: z.number(),
38
41
  emailsSent30d: z.number(),
@@ -218,6 +221,75 @@ const eventVolumeRoute = createRoute({
218
221
  },
219
222
  });
220
223
 
224
+ const bucketsMetricsRoute = createRoute({
225
+ method: "get",
226
+ path: "/buckets",
227
+ tags: ["Admin — Metrics"],
228
+ summary: "Per-bucket membership metrics",
229
+ responses: {
230
+ 200: {
231
+ content: {
232
+ "application/json": {
233
+ schema: z.object({
234
+ buckets: z.array(
235
+ z.object({
236
+ bucketId: z.string(),
237
+ name: z.string(),
238
+ size: z.number(),
239
+ entered: z.number(),
240
+ left: z.number(),
241
+ avgDwellSecs: z.number().nullable(),
242
+ }),
243
+ ),
244
+ }),
245
+ },
246
+ },
247
+ description: "Per-bucket size, entered/left totals, and average dwell",
248
+ },
249
+ },
250
+ });
251
+
252
+ const bucketTrendRoute = createRoute({
253
+ method: "get",
254
+ path: "/buckets/{id}",
255
+ tags: ["Admin — Metrics"],
256
+ summary: "Single bucket size-over-time and entered/left trend",
257
+ request: {
258
+ params: z.object({ id: z.string() }),
259
+ query: z.object({
260
+ period: z.enum(["day", "week", "month"]).default("day"),
261
+ from: z.string().datetime().optional(),
262
+ to: z.string().datetime().optional(),
263
+ }),
264
+ },
265
+ responses: {
266
+ 200: {
267
+ content: {
268
+ "application/json": {
269
+ schema: z.object({
270
+ bucketId: z.string(),
271
+ size: z.number(),
272
+ points: z.array(
273
+ z.object({
274
+ date: z.string(),
275
+ entered: z.number(),
276
+ left: z.number(),
277
+ }),
278
+ ),
279
+ }),
280
+ },
281
+ },
282
+ description: "Bucket entered/left time-series with current size",
283
+ },
284
+ 404: {
285
+ content: {
286
+ "application/json": { schema: errorSchema },
287
+ },
288
+ description: "Bucket not found",
289
+ },
290
+ },
291
+ });
292
+
221
293
  export const metricsRouter = new OpenAPIHono<AppEnv>()
222
294
  .openapi(overviewRoute, async (c) => {
223
295
  const { db } = c.get("container");
@@ -230,6 +302,8 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
230
302
  const [
231
303
  contactsTotal,
232
304
  activeJourneys,
305
+ activeBucketsRows,
306
+ bucketMembers,
233
307
  emails24h,
234
308
  emails7d,
235
309
  emails30d,
@@ -252,6 +326,27 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
252
326
  ),
253
327
  )
254
328
  .then((r) => r[0]?.count ?? 0),
329
+ // Distinct buckets with at least one active member.
330
+ db
331
+ .selectDistinct({ bucketId: bucketMemberships.bucketId })
332
+ .from(bucketMemberships)
333
+ .where(
334
+ and(
335
+ inArray(bucketMemberships.status, ["active"]),
336
+ isNull(bucketMemberships.deletedAt),
337
+ ),
338
+ ),
339
+ // Total active memberships across all buckets.
340
+ db
341
+ .select({ count: count() })
342
+ .from(bucketMemberships)
343
+ .where(
344
+ and(
345
+ inArray(bucketMemberships.status, ["active"]),
346
+ isNull(bucketMemberships.deletedAt),
347
+ ),
348
+ )
349
+ .then((r) => r[0]?.count ?? 0),
255
350
  db
256
351
  .select({ count: count() })
257
352
  .from(emailSends)
@@ -291,6 +386,8 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
291
386
  {
292
387
  totalContacts: contactsTotal,
293
388
  activeJourneys,
389
+ activeBuckets: activeBucketsRows.length,
390
+ bucketMembers,
294
391
  emailsSent24h: emails24h,
295
392
  emailsSent7d: emails7d,
296
393
  emailsSent30d: sent30d,
@@ -564,4 +661,162 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
564
661
  }));
565
662
 
566
663
  return c.json({ events }, 200);
664
+ })
665
+ .openapi(bucketsMetricsRoute, async (c) => {
666
+ const { db, bucketRegistry } = c.get("container");
667
+
668
+ const [sizes, totals, dwell] = await Promise.all([
669
+ // Current size = active, non-deleted memberships per bucket.
670
+ db
671
+ .select({
672
+ bucketId: bucketMemberships.bucketId,
673
+ size: count(),
674
+ })
675
+ .from(bucketMemberships)
676
+ .where(
677
+ and(
678
+ eq(bucketMemberships.status, "active"),
679
+ isNull(bucketMemberships.deletedAt),
680
+ ),
681
+ )
682
+ .groupBy(bucketMemberships.bucketId),
683
+ // Total entered = all rows; total left = rows that have flipped to "left".
684
+ db
685
+ .select({
686
+ bucketId: bucketMemberships.bucketId,
687
+ entered: count(),
688
+ left: sql<number>`count(*) filter (where ${bucketMemberships.status} = 'left')`,
689
+ })
690
+ .from(bucketMemberships)
691
+ .where(isNull(bucketMemberships.deletedAt))
692
+ .groupBy(bucketMemberships.bucketId),
693
+ // Average dwell — seconds between entry and leave (or now, if still active).
694
+ db
695
+ .select({
696
+ bucketId: bucketMemberships.bucketId,
697
+ avgDwell: sql<number>`avg(extract(epoch from (coalesce(${bucketMemberships.leftAt}, now()) - ${bucketMemberships.enteredAt})))`,
698
+ })
699
+ .from(bucketMemberships)
700
+ .where(isNull(bucketMemberships.deletedAt))
701
+ .groupBy(bucketMemberships.bucketId),
702
+ ]);
703
+
704
+ const sizeMap = new Map(sizes.map((r) => [r.bucketId, r.size]));
705
+ const totalsMap = new Map(
706
+ totals.map((r) => [
707
+ r.bucketId,
708
+ { entered: Number(r.entered), left: Number(r.left) },
709
+ ]),
710
+ );
711
+ const dwellMap = new Map<string, number>();
712
+ for (const row of dwell) {
713
+ if (row.avgDwell != null) {
714
+ dwellMap.set(row.bucketId, Number(row.avgDwell));
715
+ }
716
+ }
717
+
718
+ const buckets = bucketRegistry.getAll().map((b) => {
719
+ const t = totalsMap.get(b.id) ?? { entered: 0, left: 0 };
720
+ return {
721
+ bucketId: b.id,
722
+ name: b.name,
723
+ size: sizeMap.get(b.id) ?? 0,
724
+ entered: t.entered,
725
+ left: t.left,
726
+ avgDwellSecs: dwellMap.has(b.id)
727
+ ? Math.round(dwellMap.get(b.id) ?? 0)
728
+ : null,
729
+ };
730
+ });
731
+
732
+ return c.json({ buckets }, 200);
733
+ })
734
+ .openapi(bucketTrendRoute, async (c) => {
735
+ const { db, bucketRegistry } = c.get("container");
736
+ const { id } = c.req.valid("param");
737
+ const { period, from, to } = c.req.valid("query");
738
+
739
+ if (!bucketRegistry.has(id)) {
740
+ return c.json({ error: "Bucket not found" }, 404);
741
+ }
742
+
743
+ const now = new Date();
744
+ const defaultFrom = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
745
+ const fromDate = from ? new Date(from) : defaultFrom;
746
+ const toDate = to ? new Date(to) : now;
747
+
748
+ const [size, enteredRows, leftRows] = await Promise.all([
749
+ db
750
+ .select({ count: count() })
751
+ .from(bucketMemberships)
752
+ .where(
753
+ and(
754
+ eq(bucketMemberships.bucketId, id),
755
+ eq(bucketMemberships.status, "active"),
756
+ isNull(bucketMemberships.deletedAt),
757
+ ),
758
+ )
759
+ .then((r) => r[0]?.count ?? 0),
760
+ // Joins over time, bucketed on enteredAt.
761
+ db
762
+ .select({
763
+ date: sql<string>`date_trunc(${TRUNC_SQL[period]}, ${bucketMemberships.enteredAt})::text`,
764
+ count: count(),
765
+ })
766
+ .from(bucketMemberships)
767
+ .where(
768
+ and(
769
+ eq(bucketMemberships.bucketId, id),
770
+ isNull(bucketMemberships.deletedAt),
771
+ gte(bucketMemberships.enteredAt, fromDate),
772
+ lte(bucketMemberships.enteredAt, toDate),
773
+ ),
774
+ )
775
+ .groupBy(
776
+ sql`date_trunc(${TRUNC_SQL[period]}, ${bucketMemberships.enteredAt})`,
777
+ )
778
+ .orderBy(
779
+ sql`date_trunc(${TRUNC_SQL[period]}, ${bucketMemberships.enteredAt})`,
780
+ ),
781
+ // Leaves over time, bucketed on leftAt (only flipped rows have a leftAt).
782
+ db
783
+ .select({
784
+ date: sql<string>`date_trunc(${TRUNC_SQL[period]}, ${bucketMemberships.leftAt})::text`,
785
+ count: count(),
786
+ })
787
+ .from(bucketMemberships)
788
+ .where(
789
+ and(
790
+ eq(bucketMemberships.bucketId, id),
791
+ isNull(bucketMemberships.deletedAt),
792
+ isNotNull(bucketMemberships.leftAt),
793
+ gte(bucketMemberships.leftAt, fromDate),
794
+ lte(bucketMemberships.leftAt, toDate),
795
+ ),
796
+ )
797
+ .groupBy(
798
+ sql`date_trunc(${TRUNC_SQL[period]}, ${bucketMemberships.leftAt})`,
799
+ )
800
+ .orderBy(
801
+ sql`date_trunc(${TRUNC_SQL[period]}, ${bucketMemberships.leftAt})`,
802
+ ),
803
+ ]);
804
+
805
+ const pointMap = new Map<string, { entered: number; left: number }>();
806
+ for (const row of enteredRows) {
807
+ const entry = pointMap.get(row.date) ?? { entered: 0, left: 0 };
808
+ entry.entered = row.count;
809
+ pointMap.set(row.date, entry);
810
+ }
811
+ for (const row of leftRows) {
812
+ const entry = pointMap.get(row.date) ?? { entered: 0, left: 0 };
813
+ entry.left = row.count;
814
+ pointMap.set(row.date, entry);
815
+ }
816
+
817
+ const points = Array.from(pointMap.entries())
818
+ .map(([date, v]) => ({ date, entered: v.entered, left: v.left }))
819
+ .sort((a, b) => a.date.localeCompare(b.date));
820
+
821
+ return c.json({ bucketId: id, size, points }, 200);
567
822
  });
package/src/worker.ts CHANGED
@@ -1,9 +1,16 @@
1
+ import type { DefinedBucket } from "./buckets/define-bucket.js";
2
+ import { selectBucketTasks } from "./buckets/registry.js";
1
3
  import type { HogsendClient } from "./container.js";
2
4
  import type { DefinedJourney } from "./journeys/define-journey.js";
3
5
  import { selectJourneyTasks } from "./journeys/registry.js";
4
6
  import { hatchet } from "./lib/hatchet.js";
5
7
  import { getPostHog } from "./lib/posthog.js";
6
8
  import { getRedisIfConnected } from "./lib/redis.js";
9
+ import {
10
+ bucketBackfillTask,
11
+ enqueueBucketBackfills,
12
+ } from "./workflows/bucket-backfill.js";
13
+ import { bucketReconcileTask } from "./workflows/bucket-reconcile.js";
7
14
  import { checkAlertsTask } from "./workflows/check-alerts.js";
8
15
  import { importContactsTask } from "./workflows/import-contacts.js";
9
16
  import { sendEmailTask } from "./workflows/send-email.js";
@@ -11,8 +18,12 @@ import { sendEmailTask } from "./workflows/send-email.js";
11
18
  export interface CreateWorkerOptions {
12
19
  container: HogsendClient;
13
20
  journeys: DefinedJourney[];
21
+ /** Buckets whose fast-expiry timer tasks are registered. Defaults to none. */
22
+ buckets?: DefinedBucket[];
14
23
  /** Defaults to `container.env.ENABLED_JOURNEYS`. */
15
24
  enabledJourneys?: string;
25
+ /** Defaults to `container.env.ENABLED_BUCKETS`. */
26
+ enabledBuckets?: string;
16
27
  /** Extra client tasks registered alongside the built-in workflows. */
17
28
  extraWorkflows?: unknown[];
18
29
  }
@@ -27,11 +38,22 @@ export function createWorker(opts: CreateWorkerOptions): Worker {
27
38
  const enabled = opts.enabledJourneys ?? container.env.ENABLED_JOURNEYS;
28
39
  const journeyTasks = selectJourneyTasks(journeys, enabled);
29
40
 
41
+ const enabledBuckets = opts.enabledBuckets ?? container.env.ENABLED_BUCKETS;
42
+ // The single place a bucket's per-user fast-expiry timer task is constructed
43
+ // (Section 9.4): the shared `bucket:arm-expiry` durableTask, registered once iff
44
+ // any enabled bucket opts into fastExpiry. The engine-wide time-based-leave
45
+ // reconcile cron (bucketReconcileTask) is ALWAYS registered in baseWorkflows
46
+ // below (Section 10), regardless of fastExpiry.
47
+ const bucketTasks = selectBucketTasks(opts.buckets ?? [], enabledBuckets);
48
+
30
49
  const baseWorkflows = [
31
50
  sendEmailTask,
32
51
  importContactsTask,
33
52
  checkAlertsTask,
53
+ bucketReconcileTask,
54
+ bucketBackfillTask,
34
55
  ...journeyTasks,
56
+ ...bucketTasks,
35
57
  ];
36
58
  const workflows = [
37
59
  ...baseWorkflows,
@@ -58,6 +80,19 @@ export function createWorker(opts: CreateWorkerOptions): Worker {
58
80
  );
59
81
 
60
82
  await _worker.start();
83
+
84
+ // Boot-time backfill / criteria-change re-eval (Section 6.6 B): diff each
85
+ // enabled bucket's criteriaHash against bucket_configs and enqueue a
86
+ // backfill/re-eval job where it differs. Best-effort — never block worker
87
+ // start; the cron is the backstop for time-based leaves regardless.
88
+ enqueueBucketBackfills({
89
+ db: container.db,
90
+ logger: container.logger,
91
+ }).catch((err) => {
92
+ container.logger.warn("Bucket backfill enqueue (boot) failed", {
93
+ error: err instanceof Error ? err.message : String(err),
94
+ });
95
+ });
61
96
  }
62
97
 
63
98
  return { start, stop };