@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.
- package/package.json +3 -3
- package/src/buckets/check-membership.ts +499 -0
- package/src/buckets/define-bucket.ts +29 -0
- package/src/buckets/registry-singleton.ts +21 -0
- package/src/buckets/registry.ts +62 -0
- package/src/container.ts +27 -1
- package/src/env.ts +6 -0
- package/src/index.ts +39 -1
- package/src/lib/bucket-emit.ts +107 -0
- package/src/lib/bucket-posthog-sync.ts +63 -0
- package/src/lib/ingestion.ts +25 -0
- package/src/routes/admin/buckets.ts +464 -0
- package/src/routes/admin/index.ts +2 -0
- package/src/routes/admin/metrics.ts +255 -0
- package/src/worker.ts +35 -0
- package/src/workflows/bucket-backfill.ts +556 -0
- package/src/workflows/bucket-reconcile.ts +721 -0
|
@@ -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 };
|