@hogsend/core 0.2.0 → 0.4.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/core",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,7 +31,7 @@
31
31
  "drizzle-orm": "^0.45.2",
32
32
  "iana-db-timezones": "^0.3.0",
33
33
  "zod": "^4.4.3",
34
- "@hogsend/db": "^0.2.0"
34
+ "@hogsend/db": "^0.4.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "latest",
@@ -0,0 +1,163 @@
1
+ import type { DurationObject } from "../duration.js";
2
+ import type {
3
+ CompositeCondition,
4
+ ConditionEval,
5
+ EventCondition,
6
+ PropertyCondition,
7
+ } from "../types/conditions.js";
8
+
9
+ type CountOperator = NonNullable<EventCondition["operator"]>;
10
+
11
+ /** Fluent property predicate. Each terminal returns a plain PropertyCondition. */
12
+ export interface PropertyMatcher {
13
+ eq(value: string | number | boolean): PropertyCondition;
14
+ neq(value: string | number | boolean): PropertyCondition;
15
+ gt(value: number): PropertyCondition;
16
+ gte(value: number): PropertyCondition;
17
+ lt(value: number): PropertyCondition;
18
+ lte(value: number): PropertyCondition;
19
+ contains(value: string | number | boolean): PropertyCondition;
20
+ exists(): PropertyCondition;
21
+ notExists(): PropertyCondition;
22
+ }
23
+
24
+ /**
25
+ * Fluent event predicate. The optional `.within(window)` precedes the terminal
26
+ * (`b.event("x").within(days(7)).notExists()`) so every terminal still returns a
27
+ * clean EventCondition POJO — no wrapper objects to unwrap.
28
+ */
29
+ export interface EventMatcher {
30
+ /** Constrain to a rolling window — this is what makes a bucket time-based. */
31
+ within(window: DurationObject): EventMatcher;
32
+ exists(): EventCondition;
33
+ notExists(): EventCondition;
34
+ count(operator: CountOperator, value: number): EventCondition;
35
+ atLeast(value: number): EventCondition;
36
+ moreThan(value: number): EventCondition;
37
+ atMost(value: number): EventCondition;
38
+ lessThan(value: number): EventCondition;
39
+ exactly(value: number): EventCondition;
40
+ }
41
+
42
+ /**
43
+ * The fluent builder passed to a `defineBucket` criteria function. Every terminal
44
+ * returns a plain `ConditionEval` POJO — byte-identical to the declarative form —
45
+ * so the registry indexes, schema validation, reconcile cron, and Studio all keep
46
+ * working unchanged. The function runs ONCE, at bucket-definition time; it never
47
+ * executes per-user, so criteria stays introspectable data.
48
+ */
49
+ export interface CriteriaBuilder {
50
+ prop(property: string): PropertyMatcher;
51
+ event(eventName: string): EventMatcher;
52
+ /** Composite AND over the given conditions. */
53
+ all(...conditions: ConditionEval[]): CompositeCondition;
54
+ /** Composite OR over the given conditions. */
55
+ any(...conditions: ConditionEval[]): CompositeCondition;
56
+ }
57
+
58
+ class PropertyMatcherImpl implements PropertyMatcher {
59
+ private readonly property: string;
60
+ constructor(property: string) {
61
+ this.property = property;
62
+ }
63
+ private make(
64
+ operator: PropertyCondition["operator"],
65
+ value?: string | number | boolean,
66
+ ): PropertyCondition {
67
+ return {
68
+ type: "property",
69
+ property: this.property,
70
+ operator,
71
+ ...(value !== undefined ? { value } : {}),
72
+ };
73
+ }
74
+ eq(value: string | number | boolean): PropertyCondition {
75
+ return this.make("eq", value);
76
+ }
77
+ neq(value: string | number | boolean): PropertyCondition {
78
+ return this.make("neq", value);
79
+ }
80
+ gt(value: number): PropertyCondition {
81
+ return this.make("gt", value);
82
+ }
83
+ gte(value: number): PropertyCondition {
84
+ return this.make("gte", value);
85
+ }
86
+ lt(value: number): PropertyCondition {
87
+ return this.make("lt", value);
88
+ }
89
+ lte(value: number): PropertyCondition {
90
+ return this.make("lte", value);
91
+ }
92
+ contains(value: string | number | boolean): PropertyCondition {
93
+ return this.make("contains", value);
94
+ }
95
+ exists(): PropertyCondition {
96
+ return this.make("exists");
97
+ }
98
+ notExists(): PropertyCondition {
99
+ return this.make("not_exists");
100
+ }
101
+ }
102
+
103
+ class EventMatcherImpl implements EventMatcher {
104
+ private readonly eventName: string;
105
+ private readonly window?: DurationObject;
106
+ constructor(eventName: string, window?: DurationObject) {
107
+ this.eventName = eventName;
108
+ this.window = window;
109
+ }
110
+ within(window: DurationObject): EventMatcher {
111
+ return new EventMatcherImpl(this.eventName, window);
112
+ }
113
+ private make(
114
+ check: EventCondition["check"],
115
+ operator?: CountOperator,
116
+ value?: number,
117
+ ): EventCondition {
118
+ return {
119
+ type: "event",
120
+ eventName: this.eventName,
121
+ check,
122
+ ...(operator !== undefined ? { operator } : {}),
123
+ ...(value !== undefined ? { value } : {}),
124
+ ...(this.window !== undefined ? { within: this.window } : {}),
125
+ };
126
+ }
127
+ exists(): EventCondition {
128
+ return this.make("exists");
129
+ }
130
+ notExists(): EventCondition {
131
+ return this.make("not_exists");
132
+ }
133
+ count(operator: CountOperator, value: number): EventCondition {
134
+ return this.make("count", operator, value);
135
+ }
136
+ atLeast(value: number): EventCondition {
137
+ return this.make("count", "gte", value);
138
+ }
139
+ moreThan(value: number): EventCondition {
140
+ return this.make("count", "gt", value);
141
+ }
142
+ atMost(value: number): EventCondition {
143
+ return this.make("count", "lte", value);
144
+ }
145
+ lessThan(value: number): EventCondition {
146
+ return this.make("count", "lt", value);
147
+ }
148
+ exactly(value: number): EventCondition {
149
+ return this.make("count", "eq", value);
150
+ }
151
+ }
152
+
153
+ /**
154
+ * The default {@link CriteriaBuilder} instance. `defineBucket` passes this to a
155
+ * criteria function and stores the returned `ConditionEval`. Exported so it can
156
+ * also be used standalone (e.g. composing reusable criteria fragments in tests).
157
+ */
158
+ export const criteriaBuilder: CriteriaBuilder = {
159
+ prop: (property) => new PropertyMatcherImpl(property),
160
+ event: (eventName) => new EventMatcherImpl(eventName),
161
+ all: (...conditions) => ({ type: "composite", operator: "and", conditions }),
162
+ any: (...conditions) => ({ type: "composite", operator: "or", conditions }),
163
+ };
@@ -1,3 +1,9 @@
1
+ export {
2
+ type CriteriaBuilder,
3
+ criteriaBuilder,
4
+ type EventMatcher,
5
+ type PropertyMatcher,
6
+ } from "./builder.js";
1
7
  export { type ConditionContext, evaluateCondition } from "./evaluate.js";
2
8
  export { evaluateEventCondition } from "./event.js";
3
9
  export { evaluatePropertyConditions } from "./property.js";
package/src/index.ts CHANGED
@@ -1,8 +1,12 @@
1
1
  export {
2
2
  type ConditionContext,
3
+ type CriteriaBuilder,
4
+ criteriaBuilder,
5
+ type EventMatcher,
3
6
  evaluateCondition,
4
7
  evaluateEventCondition,
5
8
  evaluatePropertyConditions,
9
+ type PropertyMatcher,
6
10
  } from "./conditions/index.js";
7
11
  export {
8
12
  type DurationObject,
@@ -72,8 +72,8 @@ export const bucketMetaSchema = z
72
72
 
73
73
  criteria: conditionEvalSchema.optional(),
74
74
 
75
- reentry: z.enum(["once", "once_per_period", "unlimited"]).optional(),
76
- reentryPeriod: durationObjectSchema.optional(),
75
+ entryLimit: z.enum(["once", "once_per_period", "unlimited"]).optional(),
76
+ entryPeriod: durationObjectSchema.optional(),
77
77
 
78
78
  minDwell: durationObjectSchema.optional(),
79
79
  maxDwell: durationObjectSchema.optional(),
@@ -105,15 +105,21 @@ export const bucketMetaSchema = z
105
105
  });
106
106
  }
107
107
 
108
- // Rule 4: kind/criteria coherence. Manual buckets skip rules 1–3.
108
+ // Rule 4: kind/criteria coherence. `kind:"manual"` is declared on the
109
+ // discriminator for forward-compat (Phase 4) but is NOT implemented in v1 —
110
+ // it would register as a silent no-op (never populated by the real-time path
111
+ // or the reconcile cron). Reject it LOUDLY at registration time
112
+ // (bucketMetaSchema.parse) rather than accepting a bucket that can never
113
+ // gain members. This is a runtime-validation tightening, not a type break:
114
+ // the `kind` enum still allows declaring "manual".
109
115
  if (kind === "manual") {
110
- if (criteria !== undefined) {
111
- ctx.addIssue({
112
- code: "custom",
113
- path: ["criteria"],
114
- message: 'kind:"manual" buckets must not declare `criteria`.',
115
- });
116
- }
116
+ ctx.addIssue({
117
+ code: "custom",
118
+ path: ["kind"],
119
+ message:
120
+ 'kind:"manual" buckets are not implemented in v1; use a dynamic ' +
121
+ 'bucket (kind:"dynamic" with `criteria`) instead.',
122
+ });
117
123
  return;
118
124
  }
119
125
 
@@ -15,6 +15,11 @@ export interface BucketMeta {
15
15
  * skipped by checkBucketMembership and the reconcile cron (early-continue, the
16
16
  * Laudspeaker pattern). Declaring it up front keeps Phase 4 genuinely additive
17
17
  * — no breaking change to BucketMeta later. Default "dynamic".
18
+ *
19
+ * v1: kind:"manual" is REJECTED at registration time (bucketMetaSchema parse)
20
+ * because nothing populates a manual bucket yet — it would be a silent no-op.
21
+ * The value stays on the enum for forward-compat; use kind:"dynamic" with
22
+ * `criteria` until full manual membership ships.
18
23
  */
19
24
  kind?: "dynamic" | "manual";
20
25
 
@@ -38,8 +43,8 @@ export interface BucketMeta {
38
43
  * re-emit only after a prior leave + period elapses; "unlimited" = always.
39
44
  * Default "unlimited".
40
45
  */
41
- reentry?: "once" | "once_per_period" | "unlimited";
42
- reentryPeriod?: DurationObject;
46
+ entryLimit?: "once" | "once_per_period" | "unlimited";
47
+ entryPeriod?: DurationObject;
43
48
 
44
49
  /**
45
50
  * Anti-flap: suppress bucket:left until membership has existed at least this
@@ -54,8 +59,8 @@ export interface BucketMeta {
54
59
  * `minDwell`, which is a floor). Use it for time-boxed membership: "in this
55
60
  * bucket for exactly N days, then out".
56
61
  *
57
- * Re-entry after a maxDwell exit is governed by `reentry` (per-bucket): pair
58
- * with `reentry:"once"` / `"once_per_period"` for a HARD time-box (they stay
62
+ * Re-entry after a maxDwell exit is governed by `entryLimit` (per-bucket): pair
63
+ * with `entryLimit:"once"` / `"once_per_period"` for a HARD time-box (they stay
59
64
  * out / cool off), or leave the default `"unlimited"` for a PERIODIC FLUSH
60
65
  * (they re-join on their next qualifying event). Independent of `within` and
61
66
  * `fastExpiry`; if `minDwell` is also set it must be <= `maxDwell` (validated).
@@ -71,8 +76,12 @@ export interface BucketMeta {
71
76
  * a criteria walk if omitted; an explicit value overrides.
72
77
  * reconcileEvery: advisory cadence surfaced in Studio (the single engine-wide
73
78
  * cron sweeps all time-based buckets; per-bucket cadence is informational).
74
- * reconcileJoins: also re-evaluate JOINS in the sweep (default false — the
75
- * real-time path already catches joins on event arrival; keep the sweep
79
+ * reconcileJoins: also re-evaluate JOINS in the sweep. Tri-state:
80
+ * `false` = hard OFF (cost-bounding override, even for absence buckets);
81
+ * `true` = explicit ON; `undefined` = INFERRED — ON only for absence-shaped
82
+ * criteria (a windowed `not_exists` leg, the sole shape a clock can JOIN, so
83
+ * went-dormant works with no extra config), OFF otherwise (the real-time
84
+ * path already catches positive joins on event arrival; keep the sweep
76
85
  * O(active members)).
77
86
  * fastExpiry: opt-in per-user durable timer for sub-second absence-leave on
78
87
  * latency-critical buckets (Approach A graft). The cron remains the
@@ -96,12 +96,47 @@ export interface EmailHistoryResult {
96
96
  count: number;
97
97
  }
98
98
 
99
+ export interface WaitForEventOptions {
100
+ /** Event name to wait for (use your `Events` constant). Matched verbatim. */
101
+ event: string;
102
+ /**
103
+ * Max time to wait before resolving as timed-out. Required: an unbounded wait
104
+ * is only capped by the task's execution timeout and would fail rather than
105
+ * resume. Keep it within the journey execution timeout (720h / 30 days).
106
+ */
107
+ timeout: DurationObject;
108
+ /** Optional observability label written to `currentNodeId` while waiting. */
109
+ label?: string;
110
+ }
111
+
112
+ export interface WaitForEventResult {
113
+ /** `true` when the `timeout` elapsed first; `false` when the event fired. */
114
+ timedOut: boolean;
115
+ }
116
+
99
117
  export interface JourneyContext {
100
118
  sleep(opts: SleepOptions): Promise<SleepResult>;
101
119
 
102
120
  /** Durable sleep until an absolute instant (`Date` or ISO string). */
103
121
  sleepUntil(at: Date | string, opts?: SleepUntilOptions): Promise<SleepResult>;
104
122
 
123
+ /**
124
+ * Durably wait until THIS user emits `event`, or `timeout` elapses —
125
+ * whichever comes first. The state is marked `"waiting"` while suspended and
126
+ * `"active"` again on resume. Returns `{ timedOut }` so the journey can branch
127
+ * (e.g. send a nudge on timeout, do nothing if the event arrived).
128
+ *
129
+ * Forward-looking: only events emitted AFTER the wait is established count —
130
+ * use `ctx.history.hasEvent` to check whether something already happened.
131
+ *
132
+ * If the journey exits (via `exitOn`) or is cancelled while waiting, the run
133
+ * is aborted cleanly (a `JourneyExitedError` is thrown and handled by the
134
+ * engine) so no post-wait side effects fire. After a long wait you should
135
+ * still re-check `ctx.guard.isSubscribed()` before sending, since an
136
+ * unsubscribe does not exit the journey.
137
+ */
138
+ waitForEvent(opts: WaitForEventOptions): Promise<WaitForEventResult>;
139
+
105
140
  /** Timezone-bound fluent scheduler. Always terminates in a `Date`. */
106
141
  when: WhenBuilder;
107
142