@hogsend/engine 0.2.0 → 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.2.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.2.0",
39
- "@hogsend/db": "^0.2.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",
@@ -7,14 +7,17 @@ import {
7
7
  } from "@hogsend/core";
8
8
  import type { JourneyRegistry } from "@hogsend/core/registry";
9
9
  import { bucketMemberships, contacts, type Database } from "@hogsend/db";
10
- import { and, eq, isNull, sql } from "drizzle-orm";
10
+ import { and, desc, eq, isNotNull, isNull } from "drizzle-orm";
11
11
  import { emitBucketTransition } from "../lib/bucket-emit.js";
12
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";
13
19
  import { getBucketRegistrySingleton } from "./registry-singleton.js";
14
20
 
15
- /** The reserved prefix every bucket transition event carries. */
16
- const BUCKET_EVENT_PREFIX = "bucket:";
17
-
18
21
  export type BucketTransitionKind = "entered" | "left";
19
22
 
20
23
  export interface BucketTransition {
@@ -33,7 +36,7 @@ export interface BucketTransition {
33
36
  * RETURNING-gated mutation (partial-unique INSERT for joins, compare-and-swap
34
37
  * UPDATE for leaves — Section 6.3). On a real transition it emits
35
38
  * `bucket:entered:<id>` / `bucket:left:<id>` back through `ingestEvent`, gated on
36
- * the reentry policy and deferring leaves still inside `minDwell`.
39
+ * the entryLimit policy and deferring leaves still inside `minDwell`.
37
40
  *
38
41
  * Returns the computed transition list so a unit test can assert enter/leave/no-op
39
42
  * WITHOUT a live Hatchet (Section 14 — the testing seam). Production callers
@@ -221,18 +224,10 @@ async function handleJoin(opts: {
221
224
  const { db, registry, hatchet, logger, bucket, userId, userEmail } = opts;
222
225
 
223
226
  // 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);
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);
236
231
  const epoch = priorCount + 1;
237
232
 
238
233
  // INSERT a FRESH active row. ON CONFLICT DO NOTHING targets the partial active
@@ -241,9 +236,7 @@ async function handleJoin(opts: {
241
236
  // (the loser mutates nothing — Section 6.3 governing rule).
242
237
  const expiresAt = computeExpiresAt(bucket);
243
238
  // 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;
239
+ const maxDwellAt = computeMaxDwellAt(bucket);
247
240
  const inserted = await db
248
241
  .insert(bucketMemberships)
249
242
  .values({
@@ -284,8 +277,8 @@ async function handleJoin(opts: {
284
277
 
285
278
  // The active row is always written (Studio size must reflect reality) and the
286
279
  // 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)) {
280
+ // is gated by the entryLimit policy (Section 6.3).
281
+ if (await shouldEmitJoin({ db, bucket, userId, priorCount })) {
289
282
  await emitBucketTransition({
290
283
  db,
291
284
  registry,
@@ -299,10 +292,10 @@ async function handleJoin(opts: {
299
292
  source: "event",
300
293
  });
301
294
  } else {
302
- logger.info("Bucket join emit suppressed by reentry policy", {
295
+ logger.info("Bucket join emit suppressed by entryLimit policy", {
303
296
  bucketId: bucket.id,
304
297
  userId,
305
- reentry: bucket.reentry ?? "unlimited",
298
+ entryLimit: bucket.entryLimit ?? "unlimited",
306
299
  });
307
300
  }
308
301
 
@@ -392,28 +385,56 @@ async function handleLeave(opts: {
392
385
  }
393
386
 
394
387
  /**
395
- * The `reentry` emit gate, consulted on the JOIN transition only (Section 6.3).
388
+ * The `entryLimit` emit gate, consulted on the JOIN transition only (Section 6.3).
396
389
  * Suppressing the emit still wrote the active row and advanced the epoch — only
397
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.
398
396
  */
399
- function shouldEmitJoin(bucket: BucketMeta, priorCount: number): boolean {
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;
400
404
  // First-ever join always emits.
401
405
  if (priorCount === 0) return true;
402
- switch (bucket.reentry ?? "unlimited") {
406
+ switch (bucket.entryLimit ?? "unlimited") {
403
407
  case "unlimited":
404
408
  return true;
405
409
  case "once":
406
410
  // Any prior membership → suppress (mirrors checkEntryLimit "once").
407
411
  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;
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
+ }
417
438
  default:
418
439
  return true;
419
440
  }
@@ -467,33 +488,3 @@ async function armExpiryTimer(opts: {
467
488
  });
468
489
  }
469
490
  }
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
- }
@@ -1,6 +1,24 @@
1
- import type { BucketMeta } from "@hogsend/core/types";
1
+ import { type CriteriaBuilder, criteriaBuilder } from "@hogsend/core";
2
+ import type { BucketMeta, ConditionEval } from "@hogsend/core/types";
2
3
  import type { hatchet } from "../lib/hatchet.js";
3
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
+
4
22
  export interface DefinedBucket {
5
23
  meta: BucketMeta;
6
24
  /**
@@ -15,15 +33,20 @@ export interface DefinedBucket {
15
33
  task?: ReturnType<typeof hatchet.durableTask>;
16
34
  }
17
35
 
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 };
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 };
29
52
  }
@@ -0,0 +1,186 @@
1
+ import {
2
+ type BucketMeta,
3
+ type ConditionEval,
4
+ type DurationObject,
5
+ durationToMs,
6
+ type EventCondition,
7
+ } from "@hogsend/core";
8
+ import { bucketMemberships, type Database } from "@hogsend/db";
9
+ import { and, eq, sql } from "drizzle-orm";
10
+
11
+ /**
12
+ * The reserved prefix every bucket transition event carries. Single source of
13
+ * truth shared by the ingest recursion guard (`checkBucketMembership`) and the
14
+ * fast-expiry arming event (`bucket:arm-expiry`).
15
+ */
16
+ export const BUCKET_EVENT_PREFIX = "bucket:";
17
+
18
+ /**
19
+ * The count of ALL prior memberships (active + left, NO status filter) for a
20
+ * (userId, bucketId) pair. This is the single source for the entryCount-ordinal
21
+ * rule (Section 6.3 / 8.2): the next epoch is `1 + countPriorMemberships(...)`,
22
+ * and the same count drives the entryLimit gate. Both the real-time join path
23
+ * (`handleJoin`) and the reconcile-discovered join path (`reconcileJoinOne`)
24
+ * call this so the ordinal can never drift between the two writers.
25
+ *
26
+ * The predicate MUST stay status-agnostic — narrowing it (e.g. to active-only)
27
+ * would corrupt entryCount and the entryLimit cooldown.
28
+ */
29
+ export async function countPriorMemberships(
30
+ db: Database,
31
+ bucketId: string,
32
+ userId: string,
33
+ ): Promise<number> {
34
+ const [counted] = await db
35
+ .select({ priorCount: sql<number>`count(*)::int` })
36
+ .from(bucketMemberships)
37
+ .where(
38
+ and(
39
+ eq(bucketMemberships.userId, userId),
40
+ eq(bucketMemberships.bucketId, bucketId),
41
+ ),
42
+ );
43
+ return Number(counted?.priorCount ?? 0);
44
+ }
45
+
46
+ /**
47
+ * Find the first `EventCondition.within` rolling window in a criteria tree
48
+ * (depth-first). Returns null when no event leg carries a window. The single
49
+ * source for the membership-expiry / fastExpiry deadline math — shared so the
50
+ * three membership writers (real-time join, reconcile join, backfill join) can
51
+ * never disagree on which window drives `expiresAt`.
52
+ */
53
+ export function firstWithin(criteria: ConditionEval): DurationObject | null {
54
+ if (criteria.type === "event" && criteria.within) {
55
+ return criteria.within;
56
+ }
57
+ if (criteria.type === "composite") {
58
+ for (const child of criteria.conditions) {
59
+ const found = firstWithin(child);
60
+ if (found) return found;
61
+ }
62
+ }
63
+ return null;
64
+ }
65
+
66
+ /**
67
+ * The persisted membership-expiry / fastExpiry arming deadline. Non-time-based,
68
+ * non-fastExpiry buckets have no deadline (returns null). Time-based / fastExpiry
69
+ * buckets that carry a single `within` window get `now + within`; the reconcile
70
+ * cron and fastExpiry timer own the actual leave.
71
+ *
72
+ * Centralized so the real-time join (check-membership.ts), the reconcile-discovered
73
+ * join (bucket-reconcile.ts), and the backfill join (bucket-backfill.ts) compute
74
+ * the SAME deadline — a divergence here would let a membership-writer arm a window
75
+ * the cron/timer disagrees with.
76
+ */
77
+ export function computeExpiresAt(bucket: BucketMeta): Date | null {
78
+ if (!bucket.criteria) return null;
79
+ if (!bucket.timeBased && !bucket.fastExpiry) return null;
80
+ const within = firstWithin(bucket.criteria);
81
+ if (!within) return null;
82
+ return new Date(Date.now() + durationToMs(within));
83
+ }
84
+
85
+ /**
86
+ * The unconditional max-dwell TTL deadline, stamped once on JOIN. null when the
87
+ * bucket has no `maxDwell`; the TTL sweep filters `isNotNull(maxDwellAt)`, so an
88
+ * unset value is never force-left. Shared by all three join writers so the TTL
89
+ * stamp is computed identically.
90
+ */
91
+ export function computeMaxDwellAt(bucket: BucketMeta): Date | null {
92
+ return bucket.maxDwell
93
+ ? new Date(Date.now() + durationToMs(bucket.maxDwell))
94
+ : null;
95
+ }
96
+
97
+ /**
98
+ * The shared windowed event-count operator comparison kernel (`gt/gte/lt/lte/eq`).
99
+ * Returns the boolean of `count <operator> value`, or null for an unrecognized
100
+ * operator so each caller keeps its own default (the leave/match wrappers below).
101
+ * This is the single source for the count-operator math the reconcile SHOULD-LEAVE
102
+ * and the backfill MATCH paths both depend on.
103
+ */
104
+ function compareCount(
105
+ operator: NonNullable<EventCondition["operator"]>,
106
+ count: number,
107
+ value: number,
108
+ ): boolean | null {
109
+ switch (operator) {
110
+ case "gt":
111
+ return count > value;
112
+ case "gte":
113
+ return count >= value;
114
+ case "lt":
115
+ return count < value;
116
+ case "lte":
117
+ return count <= value;
118
+ case "eq":
119
+ return count === value;
120
+ default:
121
+ return null;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * True when a windowed `count` satisfies the (exists/not_exists/count) event
127
+ * criterion — the POSITIVE "is a member by this windowed count" decision. Shared
128
+ * by the backfill matcher path (selectEventMatchers' positive branch). Behavior is
129
+ * identical to the prior local `matchesCount`: a `count` check with no
130
+ * operator/value (or an unrecognized operator) falls back to `count > 0` / `false`
131
+ * respectively.
132
+ */
133
+ export function matchesEventCount(
134
+ criteria: EventCondition,
135
+ count: number,
136
+ ): boolean {
137
+ switch (criteria.check) {
138
+ case "exists":
139
+ return count > 0;
140
+ case "not_exists":
141
+ return count === 0;
142
+ case "count": {
143
+ if (!criteria.operator || criteria.value === undefined) return count > 0;
144
+ const result = compareCount(criteria.operator, count, criteria.value);
145
+ return result ?? false;
146
+ }
147
+ default:
148
+ return false;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * The SHOULD-LEAVE decision from a windowed count, per criterion shape — a member
154
+ * leaves when the criterion is NO LONGER satisfied. Shared by the reconcile
155
+ * cron's set-based leave path. Behavior is identical to the prior local
156
+ * `shouldLeaveByCount`: it is the per-shape NEGATION of {@link matchesEventCount}
157
+ * for `exists`/`count`, an event-reappeared check for `not_exists`, and preserves
158
+ * the `count` `default → false` for an unrecognized operator.
159
+ */
160
+ export function shouldLeaveByCount(
161
+ criteria: EventCondition,
162
+ windowedCount: number,
163
+ ): boolean {
164
+ switch (criteria.check) {
165
+ case "not_exists":
166
+ // Absence bucket: SHOULD LEAVE when an event REAPPEARS in the window.
167
+ return windowedCount > 0;
168
+ case "exists":
169
+ // Positive existence: SHOULD LEAVE when NOT EXISTS in the window.
170
+ return windowedCount === 0;
171
+ case "count": {
172
+ // SHOULD LEAVE when the windowed count NO LONGER satisfies the operator.
173
+ if (!criteria.operator || criteria.value === undefined) {
174
+ return windowedCount === 0;
175
+ }
176
+ const result = compareCount(
177
+ criteria.operator,
178
+ windowedCount,
179
+ criteria.value,
180
+ );
181
+ return result == null ? false : !result;
182
+ }
183
+ default:
184
+ return false;
185
+ }
186
+ }
@@ -10,7 +10,7 @@ const bucketSchema = z.object({
10
10
  enabled: z.boolean(),
11
11
  kind: z.enum(["dynamic", "manual"]),
12
12
  timeBased: z.boolean(),
13
- reentry: z.enum(["once", "once_per_period", "unlimited"]),
13
+ entryLimit: z.enum(["once", "once_per_period", "unlimited"]),
14
14
  counts: z.object({
15
15
  active: z.number(),
16
16
  left: z.number(),
@@ -106,7 +106,7 @@ const getRoute = createRoute({
106
106
  schema: z.object({
107
107
  bucket: bucketSchema.extend({
108
108
  criteria: z.record(z.string(), z.unknown()).optional(),
109
- reentryPeriod: z
109
+ entryPeriod: z
110
110
  .record(z.string(), z.unknown())
111
111
  .nullable()
112
112
  .optional(),
@@ -267,7 +267,7 @@ export const bucketsRouter = new OpenAPIHono<AppEnv>()
267
267
  enabled: effectiveEnabled,
268
268
  kind: b.kind ?? "dynamic",
269
269
  timeBased: b.timeBased ?? false,
270
- reentry: b.reentry ?? "unlimited",
270
+ entryLimit: b.entryLimit ?? "unlimited",
271
271
  counts: countsMap.get(b.id) ?? { ...emptyCounts },
272
272
  };
273
273
  });
@@ -366,11 +366,9 @@ export const bucketsRouter = new OpenAPIHono<AppEnv>()
366
366
  enabled: effectiveEnabled,
367
367
  kind: meta.kind ?? "dynamic",
368
368
  timeBased: meta.timeBased ?? false,
369
- reentry: meta.reentry ?? "unlimited",
369
+ entryLimit: meta.entryLimit ?? "unlimited",
370
370
  criteria: meta.criteria as Record<string, unknown> | undefined,
371
- reentryPeriod: meta.reentryPeriod as
372
- | Record<string, unknown>
373
- | undefined,
371
+ entryPeriod: meta.entryPeriod as Record<string, unknown> | undefined,
374
372
  minDwell: meta.minDwell as Record<string, unknown> | undefined,
375
373
  maxDwell: meta.maxDwell as Record<string, unknown> | undefined,
376
374
  reconcileEvery: meta.reconcileEvery as
package/src/worker.ts CHANGED
@@ -79,12 +79,12 @@ export function createWorker(opts: CreateWorkerOptions): Worker {
79
79
  `Hogsend worker started with ${journeyTasks.length} journey task(s)`,
80
80
  );
81
81
 
82
- await _worker.start();
83
-
84
82
  // 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.
83
+ // enabled bucket's criteriaHash against bucket_configs and trigger a
84
+ // backfill/re-eval run where it differs. Kicked off BEFORE the listener
85
+ // because `_worker.start()` below does NOT return until the worker stops
86
+ // anything after it is dead code at runtime. The triggers are fire-and-forget
87
+ // (runNoWait) and execute once the listener is up; best-effort, never blocks.
88
88
  enqueueBucketBackfills({
89
89
  db: container.db,
90
90
  logger: container.logger,
@@ -93,6 +93,8 @@ export function createWorker(opts: CreateWorkerOptions): Worker {
93
93
  error: err instanceof Error ? err.message : String(err),
94
94
  });
95
95
  });
96
+
97
+ await _worker.start();
96
98
  }
97
99
 
98
100
  return { start, stop };