@hogsend/core 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/core",
3
- "version": "0.2.0",
3
+ "version": "0.3.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.3.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