@hogsend/engine 0.1.1 → 0.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/engine",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,11 +35,11 @@
35
35
  "resend": "^6.12.3",
36
36
  "winston": "^3.19.0",
37
37
  "zod": "^4.4.3",
38
- "@hogsend/core": "^0.1.0",
39
- "@hogsend/db": "^0.1.0",
40
- "@hogsend/email": "^0.1.0",
41
- "@hogsend/plugin-posthog": "^0.1.0",
42
- "@hogsend/plugin-resend": "^0.1.0"
38
+ "@hogsend/core": "^0.3.0",
39
+ "@hogsend/email": "^0.3.0",
40
+ "@hogsend/db": "^0.3.0",
41
+ "@hogsend/plugin-posthog": "^0.3.0",
42
+ "@hogsend/plugin-resend": "^0.3.0"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/node": "^22.15.3",
@@ -0,0 +1,490 @@
1
+ import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
2
+ import {
3
+ type BucketMeta,
4
+ collectPropertyNames,
5
+ durationToMs,
6
+ evaluateCondition,
7
+ } from "@hogsend/core";
8
+ import type { JourneyRegistry } from "@hogsend/core/registry";
9
+ import { bucketMemberships, contacts, type Database } from "@hogsend/db";
10
+ import { and, desc, eq, isNotNull, isNull } from "drizzle-orm";
11
+ import { emitBucketTransition } from "../lib/bucket-emit.js";
12
+ import type { Logger } from "../lib/logger.js";
13
+ import {
14
+ BUCKET_EVENT_PREFIX,
15
+ computeExpiresAt,
16
+ computeMaxDwellAt,
17
+ countPriorMemberships,
18
+ } from "./membership-epoch.js";
19
+ import { getBucketRegistrySingleton } from "./registry-singleton.js";
20
+
21
+ export type BucketTransitionKind = "entered" | "left";
22
+
23
+ export interface BucketTransition {
24
+ bucketId: string;
25
+ transition: BucketTransitionKind;
26
+ }
27
+
28
+ /**
29
+ * Real-time bucket-membership re-evaluation, invoked from inside `ingestEvent`
30
+ * AFTER the `userEvents` insert / idempotency short-circuit (Section 6.1).
31
+ *
32
+ * For the ingested event it narrows the candidate buckets via the registry's
33
+ * event + property inverted indexes (Section 6.2), evaluates each candidate's
34
+ * criteria against MERGED contact state (Section 6.1 rule #3), diffs the result
35
+ * against the current `bucket_memberships` rows, and performs the atomic
36
+ * RETURNING-gated mutation (partial-unique INSERT for joins, compare-and-swap
37
+ * UPDATE for leaves — Section 6.3). On a real transition it emits
38
+ * `bucket:entered:<id>` / `bucket:left:<id>` back through `ingestEvent`, gated on
39
+ * the entryLimit policy and deferring leaves still inside `minDwell`.
40
+ *
41
+ * Returns the computed transition list so a unit test can assert enter/leave/no-op
42
+ * WITHOUT a live Hatchet (Section 14 — the testing seam). Production callers
43
+ * ignore the return value (the emission has already happened via recursion).
44
+ */
45
+ export async function checkBucketMembership(opts: {
46
+ db: Database;
47
+ /** The JOURNEY registry — forwarded into the recursive emit ingestEvent. */
48
+ registry: JourneyRegistry;
49
+ hatchet: HatchetClient;
50
+ logger: Logger;
51
+ userId: string;
52
+ userEmail: string | null;
53
+ event: string;
54
+ properties: Record<string, unknown>;
55
+ /** Optional override; defaults to the process bucket-registry singleton. */
56
+ bucketRegistry?: ReturnType<typeof getBucketRegistrySingleton>;
57
+ }): Promise<BucketTransition[]> {
58
+ const {
59
+ db,
60
+ registry,
61
+ hatchet,
62
+ logger,
63
+ userId,
64
+ userEmail,
65
+ event,
66
+ properties,
67
+ } = opts;
68
+
69
+ // (1) Recursion guard — MUST be first. bucket:-prefixed events are transition
70
+ // rows (still written to userEvents / pushed to Hatchet / run through
71
+ // checkExits) but MUST NOT trigger bucket re-evaluation, else the emit recurses
72
+ // forever. ingestEvent has no built-in re-entry guard, so this prefix check is
73
+ // the bound on recursion (Section 6.1 rule #1).
74
+ if (event.startsWith(BUCKET_EVENT_PREFIX)) {
75
+ return [];
76
+ }
77
+
78
+ // The bucket registry is resolved separately from the journey registry; the
79
+ // two are never conflated (Section 6.1 signature note).
80
+ const bucketRegistry = opts.bucketRegistry ?? getBucketRegistrySingleton();
81
+
82
+ // (2) Candidate narrowing — the UNION of buckets referencing this event name
83
+ // (eventIndex + the degenerate wildcard set) and buckets referencing any
84
+ // property present in this payload (propertyIndex). Section 6.2.
85
+ const candidateMap = new Map<string, BucketMeta>();
86
+ for (const bucket of bucketRegistry.getByReferencedEvent(event)) {
87
+ candidateMap.set(bucket.id, bucket);
88
+ }
89
+ for (const key of Object.keys(properties ?? {})) {
90
+ for (const bucket of bucketRegistry.getByReferencedProperty(key)) {
91
+ candidateMap.set(bucket.id, bucket);
92
+ }
93
+ }
94
+
95
+ if (candidateMap.size === 0) {
96
+ return [];
97
+ }
98
+
99
+ const candidates = Array.from(candidateMap.values()).filter(
100
+ // manual buckets are not criteria-driven; they never appear in the indexes,
101
+ // but guard defensively. enabled is the static load-time flag (the DB
102
+ // bucket_configs override is a later-phase concern, not read on this hot
103
+ // path — Section 6.2).
104
+ (bucket) =>
105
+ bucket.enabled && bucket.kind !== "manual" && bucket.criteria != null,
106
+ );
107
+
108
+ if (candidates.length === 0) {
109
+ return [];
110
+ }
111
+
112
+ // (3) Property predicates evaluate against MERGED contact state, NOT the bare
113
+ // event payload (Section 6.1 rule #3). Read the EXISTING contacts row ONCE iff
114
+ // any surviving candidate references a property — pure event/count buckets skip
115
+ // the read entirely. We read the row that already exists (not the one
116
+ // upsertContact is concurrently writing) so we do not depend on the
117
+ // fire-and-forget upsert having run.
118
+ const needsContactState = candidates.some(
119
+ (bucket) =>
120
+ bucket.criteria != null &&
121
+ collectPropertyNames(bucket.criteria).length > 0,
122
+ );
123
+
124
+ let contactProperties: Record<string, unknown> = {};
125
+ let contactDeleted = false;
126
+ if (needsContactState) {
127
+ const [contact] = await db
128
+ .select({
129
+ properties: contacts.properties,
130
+ deletedAt: contacts.deletedAt,
131
+ })
132
+ .from(contacts)
133
+ .where(eq(contacts.externalId, userId))
134
+ .limit(1);
135
+ if (contact) {
136
+ contactProperties =
137
+ (contact.properties as Record<string, unknown> | null) ?? {};
138
+ contactDeleted = contact.deletedAt != null;
139
+ }
140
+ }
141
+
142
+ // GDPR: never (re-)evaluate or emit for a soft-deleted contact (Section 8.6).
143
+ if (contactDeleted) {
144
+ return [];
145
+ }
146
+
147
+ // event payload overlays cumulative contact state.
148
+ const journeyContext: Record<string, unknown> = {
149
+ ...contactProperties,
150
+ ...(properties ?? {}),
151
+ };
152
+
153
+ const transitions: BucketTransition[] = [];
154
+
155
+ for (const bucket of candidates) {
156
+ if (!bucket.criteria) continue;
157
+
158
+ // wasMember — current active, non-deleted membership row (cheap pre-filter;
159
+ // the authoritative guard is the RETURNING-gated mutation below).
160
+ const active = await db.query.bucketMemberships.findFirst({
161
+ where: and(
162
+ eq(bucketMemberships.userId, userId),
163
+ eq(bucketMemberships.bucketId, bucket.id),
164
+ eq(bucketMemberships.status, "active"),
165
+ isNull(bucketMemberships.deletedAt),
166
+ ),
167
+ });
168
+ const wasMember = !!active;
169
+
170
+ // isMember — the criteria evaluation. event/count sub-conditions read
171
+ // userEvents (the just-stored row is visible on the same connection — the
172
+ // documented no-pooler assumption, Section 6.1 rule #2); property
173
+ // sub-conditions read the merged journeyContext.
174
+ const isMember = await evaluateCondition({
175
+ condition: bucket.criteria,
176
+ ctx: { db, userId, journeyContext },
177
+ });
178
+
179
+ if (!wasMember && isMember) {
180
+ const transition = await handleJoin({
181
+ db,
182
+ registry,
183
+ hatchet,
184
+ logger,
185
+ bucket,
186
+ userId,
187
+ userEmail,
188
+ });
189
+ if (transition) transitions.push(transition);
190
+ } else if (wasMember && isMember) {
191
+ // stable member → no transition, no emit. Cheap observability bump.
192
+ await db
193
+ .update(bucketMemberships)
194
+ .set({ lastEvaluatedAt: new Date() })
195
+ .where(eq(bucketMemberships.id, active.id));
196
+ } else if (wasMember && !isMember) {
197
+ const transition = await handleLeave({
198
+ db,
199
+ registry,
200
+ hatchet,
201
+ logger,
202
+ bucket,
203
+ active,
204
+ userId,
205
+ userEmail,
206
+ });
207
+ if (transition) transitions.push(transition);
208
+ }
209
+ // !wasMember && !isMember → nothing.
210
+ }
211
+
212
+ return transitions;
213
+ }
214
+
215
+ async function handleJoin(opts: {
216
+ db: Database;
217
+ registry: JourneyRegistry;
218
+ hatchet: HatchetClient;
219
+ logger: Logger;
220
+ bucket: BucketMeta;
221
+ userId: string;
222
+ userEmail: string | null;
223
+ }): Promise<BucketTransition | null> {
224
+ const { db, registry, hatchet, logger, bucket, userId, userEmail } = opts;
225
+
226
+ // entryCount ordinal = 1 + count of ALL prior memberships (active + left) for
227
+ // this (user, bucket) (Section 6.3 / 8.2). priorCount also drives the entryLimit
228
+ // gate. Shared with the reconcile-discovered join path so the ordinal can
229
+ // never drift between the two writers.
230
+ const priorCount = await countPriorMemberships(db, bucket.id, userId);
231
+ const epoch = priorCount + 1;
232
+
233
+ // INSERT a FRESH active row. ON CONFLICT DO NOTHING targets the partial active
234
+ // unique index (uq_user_bucket_active): a concurrent emitter that already
235
+ // inserted the active row makes THIS insert return zero rows → we do NOT emit
236
+ // (the loser mutates nothing — Section 6.3 governing rule).
237
+ const expiresAt = computeExpiresAt(bucket);
238
+ // Unconditional TTL deadline — set once on join, swept by the reconcile cron.
239
+ const maxDwellAt = computeMaxDwellAt(bucket);
240
+ const inserted = await db
241
+ .insert(bucketMemberships)
242
+ .values({
243
+ userId,
244
+ userEmail,
245
+ bucketId: bucket.id,
246
+ status: "active",
247
+ source: "event",
248
+ entryCount: epoch,
249
+ expiresAt,
250
+ maxDwellAt,
251
+ lastEvaluatedAt: new Date(),
252
+ })
253
+ .onConflictDoNothing()
254
+ .returning({ id: bucketMemberships.id });
255
+
256
+ const insertedRow = inserted[0];
257
+ if (!insertedRow) {
258
+ // Lost the race; the winner emits. We did not change a row → no emit.
259
+ return null;
260
+ }
261
+
262
+ // Arm the per-user fast-expiry durable timer (Section 6.5) AFTER the active row
263
+ // is written. The cron remains the authoritative backstop, so a push failure
264
+ // is best-effort. We arm against the persisted expiresAt so the timer's CAS on
265
+ // wake matches the row (or no-ops if a later event re-armed the window).
266
+ if (bucket.fastExpiry && expiresAt) {
267
+ await armExpiryTimer({
268
+ hatchet,
269
+ logger,
270
+ bucket,
271
+ rowId: insertedRow.id,
272
+ userId,
273
+ userEmail,
274
+ expiresAt,
275
+ });
276
+ }
277
+
278
+ // The active row is always written (Studio size must reflect reality) and the
279
+ // epoch always advances via the real insert; only the bucket:entered emission
280
+ // is gated by the entryLimit policy (Section 6.3).
281
+ if (await shouldEmitJoin({ db, bucket, userId, priorCount })) {
282
+ await emitBucketTransition({
283
+ db,
284
+ registry,
285
+ hatchet,
286
+ logger,
287
+ kind: "entered",
288
+ bucket,
289
+ userId,
290
+ userEmail,
291
+ epoch,
292
+ source: "event",
293
+ });
294
+ } else {
295
+ logger.info("Bucket join emit suppressed by entryLimit policy", {
296
+ bucketId: bucket.id,
297
+ userId,
298
+ entryLimit: bucket.entryLimit ?? "unlimited",
299
+ });
300
+ }
301
+
302
+ return { bucketId: bucket.id, transition: "entered" };
303
+ }
304
+
305
+ async function handleLeave(opts: {
306
+ db: Database;
307
+ registry: JourneyRegistry;
308
+ hatchet: HatchetClient;
309
+ logger: Logger;
310
+ bucket: BucketMeta;
311
+ active: typeof bucketMemberships.$inferSelect;
312
+ userId: string;
313
+ userEmail: string | null;
314
+ }): Promise<BucketTransition | null> {
315
+ const { db, registry, hatchet, logger, bucket, active, userId, userEmail } =
316
+ opts;
317
+
318
+ // minDwell DEFERS (never silently drops) the leave (Section 6.3). We set a
319
+ // pending-leave deadline on expiresAt = enteredAt + minDwell so the reconcile
320
+ // cron / fastExpiry timer re-checks after the dwell window and emits the leave
321
+ // via the CAS path. We do NOT emit now.
322
+ if (withinMinDwell(active, bucket)) {
323
+ const deadline = new Date(
324
+ active.enteredAt.getTime() +
325
+ durationToMs(bucket.minDwell as NonNullable<BucketMeta["minDwell"]>),
326
+ );
327
+ await db
328
+ .update(bucketMemberships)
329
+ .set({ expiresAt: deadline, lastEvaluatedAt: new Date() })
330
+ .where(
331
+ and(
332
+ eq(bucketMemberships.id, active.id),
333
+ eq(bucketMemberships.status, "active"),
334
+ ),
335
+ );
336
+ logger.info("Bucket leave deferred by minDwell", {
337
+ bucketId: bucket.id,
338
+ userId,
339
+ deferUntil: deadline.toISOString(),
340
+ });
341
+ return null;
342
+ }
343
+
344
+ // Compare-and-swap: only the emitter whose UPDATE actually flips the active row
345
+ // emits. A concurrent emitter that already flipped it matches zero rows → no
346
+ // emit (Section 6.3).
347
+ const left = await db
348
+ .update(bucketMemberships)
349
+ .set({
350
+ status: "left",
351
+ leftAt: new Date(),
352
+ lastEvaluatedAt: new Date(),
353
+ updatedAt: new Date(),
354
+ })
355
+ .where(
356
+ and(
357
+ eq(bucketMemberships.id, active.id),
358
+ eq(bucketMemberships.status, "active"),
359
+ ),
360
+ )
361
+ .returning({
362
+ id: bucketMemberships.id,
363
+ entryCount: bucketMemberships.entryCount,
364
+ });
365
+
366
+ const flipped = left[0];
367
+ if (!flipped) {
368
+ return null;
369
+ }
370
+
371
+ await emitBucketTransition({
372
+ db,
373
+ registry,
374
+ hatchet,
375
+ logger,
376
+ kind: "left",
377
+ bucket,
378
+ userId,
379
+ userEmail,
380
+ epoch: flipped.entryCount,
381
+ source: "event",
382
+ });
383
+
384
+ return { bucketId: bucket.id, transition: "left" };
385
+ }
386
+
387
+ /**
388
+ * The `entryLimit` emit gate, consulted on the JOIN transition only (Section 6.3).
389
+ * Suppressing the emit still wrote the active row and advanced the epoch — only
390
+ * the `bucket:entered` ingestEvent recursion is skipped.
391
+ *
392
+ * The engine now enforces `once_per_period` PRECISELY: it reads the most-recent
393
+ * prior LEAVE (`status:"left"` with `leftAt` set) and emits only once the
394
+ * configured `entryPeriod` has elapsed since that leave. The journey-side
395
+ * entryLimit/entryPeriod is a redundant backstop, no longer the sole gate.
396
+ */
397
+ export async function shouldEmitJoin(opts: {
398
+ db: Database;
399
+ bucket: BucketMeta;
400
+ userId: string;
401
+ priorCount: number;
402
+ }): Promise<boolean> {
403
+ const { db, bucket, userId, priorCount } = opts;
404
+ // First-ever join always emits.
405
+ if (priorCount === 0) return true;
406
+ switch (bucket.entryLimit ?? "unlimited") {
407
+ case "unlimited":
408
+ return true;
409
+ case "once":
410
+ // Any prior membership → suppress (mirrors checkEntryLimit "once").
411
+ return false;
412
+ case "once_per_period": {
413
+ // Back-compat: with no period configured, preserve 0.2.0 behavior (emit)
414
+ // and defer cooldown to the journey-side entryLimit/entryPeriod.
415
+ if (!bucket.entryPeriod) return true;
416
+ // Look up the most-recent COMPLETED prior cycle. Scoping to status:"left"
417
+ // (not "any prior row") makes this order-independent and race-safe against
418
+ // the active row we just inserted at this join — that row has no leftAt and
419
+ // status:"active", so it can never be mistaken for the prior cycle.
420
+ const [prior] = await db
421
+ .select({ leftAt: bucketMemberships.leftAt })
422
+ .from(bucketMemberships)
423
+ .where(
424
+ and(
425
+ eq(bucketMemberships.userId, userId),
426
+ eq(bucketMemberships.bucketId, bucket.id),
427
+ eq(bucketMemberships.status, "left"),
428
+ isNotNull(bucketMemberships.leftAt),
429
+ ),
430
+ )
431
+ .orderBy(desc(bucketMemberships.leftAt))
432
+ .limit(1);
433
+ // No completed prior cycle to cool off from → emit.
434
+ if (!prior?.leftAt) return true;
435
+ const elapsed = Date.now() - prior.leftAt.getTime();
436
+ return elapsed >= durationToMs(bucket.entryPeriod);
437
+ }
438
+ default:
439
+ return true;
440
+ }
441
+ }
442
+
443
+ /** True while the active membership is still inside its minDwell window. */
444
+ function withinMinDwell(
445
+ active: typeof bucketMemberships.$inferSelect,
446
+ bucket: BucketMeta,
447
+ ): boolean {
448
+ if (!bucket.minDwell) return false;
449
+ const elapsed = Date.now() - active.enteredAt.getTime();
450
+ return elapsed < durationToMs(bucket.minDwell);
451
+ }
452
+
453
+ /**
454
+ * Arm the shared per-user fast-expiry durable timer by pushing a
455
+ * `bucket:arm-expiry` event (Section 6.5). The single shared `bucketExpiryTask`
456
+ * durableTask (workflows/bucket-reconcile.ts) routes on `onEvents:
457
+ * ["bucket:arm-expiry"]`, durably sleeps to the deadline, then leaves via a CAS
458
+ * keyed on the ARMED `expiresAt`. The `bucket:`-prefixed event is recursion-guarded
459
+ * by `checkBucketMembership`, so arming does NOT re-enter bucket evaluation.
460
+ * Best-effort: the cron is the authoritative backstop, so a push failure is logged
461
+ * and swallowed.
462
+ */
463
+ async function armExpiryTimer(opts: {
464
+ hatchet: HatchetClient;
465
+ logger: Logger;
466
+ bucket: BucketMeta;
467
+ rowId: string;
468
+ userId: string;
469
+ userEmail: string | null;
470
+ expiresAt: Date;
471
+ }): Promise<void> {
472
+ const { hatchet, logger, bucket, rowId, userId, userEmail, expiresAt } = opts;
473
+ const msUntilExpiry = Math.max(0, expiresAt.getTime() - Date.now());
474
+ try {
475
+ await hatchet.events.push("bucket:arm-expiry", {
476
+ rowId,
477
+ bucketId: bucket.id,
478
+ userId,
479
+ userEmail,
480
+ armedExpiresAt: expiresAt.toISOString(),
481
+ msUntilExpiry,
482
+ });
483
+ } catch (err) {
484
+ logger.warn("Bucket fast-expiry arm failed (cron backstop covers it)", {
485
+ bucketId: bucket.id,
486
+ userId,
487
+ error: err instanceof Error ? err.message : String(err),
488
+ });
489
+ }
490
+ }
@@ -0,0 +1,52 @@
1
+ import { type CriteriaBuilder, criteriaBuilder } from "@hogsend/core";
2
+ import type { BucketMeta, ConditionEval } from "@hogsend/core/types";
3
+ import type { hatchet } from "../lib/hatchet.js";
4
+
5
+ /**
6
+ * `criteria` may be authored two ways:
7
+ * - declaratively, as a `ConditionEval` data tree, or
8
+ * - with the fluent builder, as `(b) => b.all(b.prop("plan").eq("trial"), ...)`.
9
+ * The builder form runs ONCE here and returns a `ConditionEval`, so everything
10
+ * downstream (registry indexes, schema validation, reconcile cron, Studio) only
11
+ * ever sees the canonical declarative data.
12
+ */
13
+ export type CriteriaInput =
14
+ | ConditionEval
15
+ | ((b: CriteriaBuilder) => ConditionEval);
16
+
17
+ /** `BucketMeta` as authored — `criteria` accepts the builder function too. */
18
+ export type BucketMetaInput = Omit<BucketMeta, "criteria"> & {
19
+ criteria?: CriteriaInput;
20
+ };
21
+
22
+ export interface DefinedBucket {
23
+ meta: BucketMeta;
24
+ /**
25
+ * The only task a bucket ever holds is the opt-in per-user fast-expiry timer,
26
+ * which is a DURABLE task (it `ctx.sleepFor`s — Section 6.5), so the type MUST
27
+ * be the durableTask return type, mirroring
28
+ * `DefinedJourney.task = ReturnType<typeof hatchet.durableTask>`
29
+ * (define-journey.ts:34) — NOT `hatchet.task`. The common case is
30
+ * declarative-only (no task), like webhookSources; the engine-wide
31
+ * `bucketReconcileTask` handles time-based leaves regardless.
32
+ */
33
+ task?: ReturnType<typeof hatchet.durableTask>;
34
+ }
35
+
36
+ export function defineBucket(options: {
37
+ meta: BucketMetaInput;
38
+ }): DefinedBucket {
39
+ // The ONLY transform defineBucket performs is resolving a builder-function
40
+ // `criteria` to its `ConditionEval` (a one-shot, definition-time call). It does
41
+ // NOT validate or build any task — `bucketMetaSchema.parse` still happens at
42
+ // BucketRegistry.register, and the fast-expiry durableTask is synthesized later
43
+ // by selectBucketTasks (Section 9.4). A declarative `criteria` passes straight
44
+ // through unchanged, so existing buckets are unaffected.
45
+ const { criteria, ...rest } = options.meta;
46
+ const meta: BucketMeta = {
47
+ ...rest,
48
+ criteria:
49
+ typeof criteria === "function" ? criteria(criteriaBuilder) : criteria,
50
+ };
51
+ return { meta };
52
+ }