@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 +2 -2
- package/src/conditions/builder.ts +163 -0
- package/src/conditions/index.ts +6 -0
- package/src/index.ts +4 -0
- package/src/schemas/bucket.schema.ts +16 -10
- package/src/types/bucket.ts +15 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/core",
|
|
3
|
-
"version": "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.
|
|
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
|
+
};
|
package/src/conditions/index.ts
CHANGED
|
@@ -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
|
-
|
|
76
|
-
|
|
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.
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
package/src/types/bucket.ts
CHANGED
|
@@ -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
|
-
|
|
42
|
-
|
|
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 `
|
|
58
|
-
* with `
|
|
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
|
|
75
|
-
*
|
|
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
|