@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 +6 -6
- package/src/buckets/check-membership.ts +490 -0
- package/src/buckets/define-bucket.ts +52 -0
- package/src/buckets/membership-epoch.ts +186 -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 +462 -0
- package/src/routes/admin/index.ts +2 -0
- package/src/routes/admin/metrics.ts +255 -0
- package/src/worker.ts +37 -0
- package/src/workflows/bucket-backfill.ts +593 -0
- package/src/workflows/bucket-reconcile.ts +1010 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/engine",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
39
|
-
"@hogsend/
|
|
40
|
-
"@hogsend/
|
|
41
|
-
"@hogsend/plugin-posthog": "^0.
|
|
42
|
-
"@hogsend/plugin-resend": "^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
|
+
}
|