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