@hogsend/core 0.1.0 → 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 +2 -2
- package/src/index.ts +7 -2
- package/src/registry/bucket.ts +119 -0
- package/src/registry/index.ts +6 -0
- package/src/schemas/bucket.schema.ts +166 -0
- package/src/schemas/index.ts +1 -0
- package/src/types/bucket.ts +93 -0
- package/src/types/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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.2.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@types/node": "latest",
|
package/src/index.ts
CHANGED
|
@@ -11,7 +11,12 @@ export {
|
|
|
11
11
|
hours,
|
|
12
12
|
minutes,
|
|
13
13
|
} from "./duration.js";
|
|
14
|
-
export {
|
|
14
|
+
export {
|
|
15
|
+
BucketRegistry,
|
|
16
|
+
collectEventNames,
|
|
17
|
+
collectPropertyNames,
|
|
18
|
+
JourneyRegistry,
|
|
19
|
+
} from "./registry/index.js";
|
|
15
20
|
export * from "./schedule/index.js";
|
|
16
|
-
export { journeyMetaSchema } from "./schemas/index.js";
|
|
21
|
+
export { bucketMetaSchema, journeyMetaSchema } from "./schemas/index.js";
|
|
17
22
|
export * from "./types/index.js";
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { bucketMetaSchema } from "../schemas/index.js";
|
|
2
|
+
import type { BucketMeta, ConditionEval } from "../types/index.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Walk a ConditionEval tree, collecting every EventCondition.eventName. Pure
|
|
6
|
+
* tree walk over the discriminated union, mirroring core/conditions/event.ts.
|
|
7
|
+
*/
|
|
8
|
+
export function collectEventNames(criteria: ConditionEval): string[] {
|
|
9
|
+
const names: string[] = [];
|
|
10
|
+
const visit = (condition: ConditionEval): void => {
|
|
11
|
+
switch (condition.type) {
|
|
12
|
+
case "event":
|
|
13
|
+
names.push(condition.eventName);
|
|
14
|
+
break;
|
|
15
|
+
case "composite":
|
|
16
|
+
for (const child of condition.conditions) {
|
|
17
|
+
visit(child);
|
|
18
|
+
}
|
|
19
|
+
break;
|
|
20
|
+
default:
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
visit(criteria);
|
|
25
|
+
return names;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Walk a ConditionEval tree, collecting every PropertyCondition.property. Pure
|
|
30
|
+
* tree walk over the discriminated union.
|
|
31
|
+
*/
|
|
32
|
+
export function collectPropertyNames(criteria: ConditionEval): string[] {
|
|
33
|
+
const names: string[] = [];
|
|
34
|
+
const visit = (condition: ConditionEval): void => {
|
|
35
|
+
switch (condition.type) {
|
|
36
|
+
case "property":
|
|
37
|
+
names.push(condition.property);
|
|
38
|
+
break;
|
|
39
|
+
case "composite":
|
|
40
|
+
for (const child of condition.conditions) {
|
|
41
|
+
visit(child);
|
|
42
|
+
}
|
|
43
|
+
break;
|
|
44
|
+
default:
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
visit(criteria);
|
|
49
|
+
return names;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class BucketRegistry {
|
|
53
|
+
private buckets: Map<string, BucketMeta> = new Map();
|
|
54
|
+
private eventIndex: Map<string, BucketMeta[]> = new Map();
|
|
55
|
+
private propertyIndex: Map<string, BucketMeta[]> = new Map();
|
|
56
|
+
// degenerate: criteria reference neither a concrete event nor any property
|
|
57
|
+
private wildcard: BucketMeta[] = [];
|
|
58
|
+
|
|
59
|
+
register(bucket: BucketMeta): void {
|
|
60
|
+
const parsed = bucketMetaSchema.parse(bucket);
|
|
61
|
+
const validated = parsed as unknown as BucketMeta;
|
|
62
|
+
|
|
63
|
+
this.buckets.set(validated.id, validated);
|
|
64
|
+
|
|
65
|
+
// manual buckets are not criteria-driven → not indexed for real-time eval
|
|
66
|
+
if (validated.kind === "manual" || !validated.criteria) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const events = collectEventNames(validated.criteria);
|
|
71
|
+
const props = collectPropertyNames(validated.criteria);
|
|
72
|
+
|
|
73
|
+
for (const eventName of events) {
|
|
74
|
+
const existing = this.eventIndex.get(eventName) ?? [];
|
|
75
|
+
existing.push(validated);
|
|
76
|
+
this.eventIndex.set(eventName, existing);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const propName of props) {
|
|
80
|
+
const existing = this.propertyIndex.get(propName) ?? [];
|
|
81
|
+
existing.push(validated);
|
|
82
|
+
this.propertyIndex.set(propName, existing);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// "*" ONLY for criteria referencing neither a concrete event nor a property
|
|
86
|
+
// (degenerate; rare under the at-least-one-positive rule).
|
|
87
|
+
if (events.length === 0 && props.length === 0) {
|
|
88
|
+
this.wildcard.push(validated);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
get(id: string): BucketMeta | undefined {
|
|
93
|
+
return this.buckets.get(id);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
getByReferencedEvent(eventName: string): BucketMeta[] {
|
|
97
|
+
return [...(this.eventIndex.get(eventName) ?? []), ...this.wildcard];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
getByReferencedProperty(propName: string): BucketMeta[] {
|
|
101
|
+
return this.propertyIndex.get(propName) ?? [];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
getAll(): BucketMeta[] {
|
|
105
|
+
return Array.from(this.buckets.values());
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
getEnabled(): BucketMeta[] {
|
|
109
|
+
return this.getAll().filter((b) => b.enabled);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
has(id: string): boolean {
|
|
113
|
+
return this.buckets.has(id);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
count(): number {
|
|
117
|
+
return this.buckets.size;
|
|
118
|
+
}
|
|
119
|
+
}
|
package/src/registry/index.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { journeyMetaSchema } from "../schemas/index.js";
|
|
2
2
|
import type { JourneyMeta } from "../types/index.js";
|
|
3
3
|
|
|
4
|
+
export {
|
|
5
|
+
BucketRegistry,
|
|
6
|
+
collectEventNames,
|
|
7
|
+
collectPropertyNames,
|
|
8
|
+
} from "./bucket.js";
|
|
9
|
+
|
|
4
10
|
export class JourneyRegistry {
|
|
5
11
|
private journeys: Map<string, JourneyMeta> = new Map();
|
|
6
12
|
private triggerIndex: Map<string, JourneyMeta[]> = new Map();
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { durationToMs } from "../duration.js";
|
|
3
|
+
import type {
|
|
4
|
+
CompositeCondition,
|
|
5
|
+
ConditionEval,
|
|
6
|
+
EmailEngagementCondition,
|
|
7
|
+
EventCondition,
|
|
8
|
+
PropertyCondition,
|
|
9
|
+
} from "../types/conditions.js";
|
|
10
|
+
import { conditionEvalSchema } from "./journey.schema.js";
|
|
11
|
+
|
|
12
|
+
const durationObjectSchema = z.object({
|
|
13
|
+
hours: z.number().optional(),
|
|
14
|
+
minutes: z.number().optional(),
|
|
15
|
+
seconds: z.number().optional(),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Walk a ConditionEval tree, yielding every leaf node (property / event /
|
|
20
|
+
* email_engagement). Composites recurse into their `conditions` array. Mirrors
|
|
21
|
+
* the pure discriminated-union walks in core/conditions.
|
|
22
|
+
*/
|
|
23
|
+
function* walkConditions(
|
|
24
|
+
condition: ConditionEval,
|
|
25
|
+
): Generator<PropertyCondition | EventCondition | EmailEngagementCondition> {
|
|
26
|
+
if (condition.type === "composite") {
|
|
27
|
+
for (const child of (condition as CompositeCondition).conditions) {
|
|
28
|
+
yield* walkConditions(child);
|
|
29
|
+
}
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
yield condition;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A leaf condition is "negative" when satisfying the bucket means the absence /
|
|
37
|
+
* inequality of data. A dynamic bucket whose every leaf is negative is
|
|
38
|
+
* degenerate/unbounded (the Customer.io rule), so at least one positive leaf is
|
|
39
|
+
* required.
|
|
40
|
+
*
|
|
41
|
+
* Exception: a TIME-BOUNDED behavioral absence — `event` `not_exists` with a
|
|
42
|
+
* `within` window ("did NOT do X in the last N") — is the canonical
|
|
43
|
+
* dormancy/churn predicate (e.g. the `went-dormant` bucket and the whole
|
|
44
|
+
* cron-reconcile leave path exist for it). It is bounded by its window, so it
|
|
45
|
+
* is NOT degenerate and counts as a legitimate anchor. Only an UNBOUNDED
|
|
46
|
+
* absence (`not_exists` with no `within`) matches nearly everyone and is
|
|
47
|
+
* treated as a pure-negation leaf.
|
|
48
|
+
*/
|
|
49
|
+
function isNegativeLeaf(
|
|
50
|
+
leaf: PropertyCondition | EventCondition | EmailEngagementCondition,
|
|
51
|
+
): boolean {
|
|
52
|
+
switch (leaf.type) {
|
|
53
|
+
case "property":
|
|
54
|
+
return leaf.operator === "neq" || leaf.operator === "not_exists";
|
|
55
|
+
case "event":
|
|
56
|
+
return leaf.check === "not_exists" && leaf.within === undefined;
|
|
57
|
+
case "email_engagement":
|
|
58
|
+
return leaf.check === "not_opened" || leaf.check === "not_clicked";
|
|
59
|
+
default:
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const bucketMetaSchema = z
|
|
65
|
+
.object({
|
|
66
|
+
id: z.string().min(1),
|
|
67
|
+
name: z.string().min(1),
|
|
68
|
+
description: z.string().optional(),
|
|
69
|
+
enabled: z.boolean(),
|
|
70
|
+
|
|
71
|
+
kind: z.enum(["dynamic", "manual"]).optional(),
|
|
72
|
+
|
|
73
|
+
criteria: conditionEvalSchema.optional(),
|
|
74
|
+
|
|
75
|
+
reentry: z.enum(["once", "once_per_period", "unlimited"]).optional(),
|
|
76
|
+
reentryPeriod: durationObjectSchema.optional(),
|
|
77
|
+
|
|
78
|
+
minDwell: durationObjectSchema.optional(),
|
|
79
|
+
maxDwell: durationObjectSchema.optional(),
|
|
80
|
+
|
|
81
|
+
timeBased: z.boolean().optional(),
|
|
82
|
+
reconcileEvery: durationObjectSchema.optional(),
|
|
83
|
+
reconcileJoins: z.boolean().optional(),
|
|
84
|
+
fastExpiry: z.boolean().optional(),
|
|
85
|
+
|
|
86
|
+
syncToPostHog: z.boolean().optional(),
|
|
87
|
+
postHogPropertyKey: z.string().optional(),
|
|
88
|
+
})
|
|
89
|
+
.superRefine((meta, ctx) => {
|
|
90
|
+
const kind = meta.kind ?? "dynamic";
|
|
91
|
+
const criteria = meta.criteria as ConditionEval | undefined;
|
|
92
|
+
|
|
93
|
+
// minDwell is a floor, maxDwell an unconditional ceiling. A ceiling below the
|
|
94
|
+
// floor is contradictory — the TTL leave would be permanently blocked by the
|
|
95
|
+
// minDwell guard. Applies regardless of kind.
|
|
96
|
+
if (
|
|
97
|
+
meta.minDwell &&
|
|
98
|
+
meta.maxDwell &&
|
|
99
|
+
durationToMs(meta.maxDwell) < durationToMs(meta.minDwell)
|
|
100
|
+
) {
|
|
101
|
+
ctx.addIssue({
|
|
102
|
+
code: "custom",
|
|
103
|
+
path: ["maxDwell"],
|
|
104
|
+
message: "maxDwell must be greater than or equal to minDwell.",
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Rule 4: kind/criteria coherence. Manual buckets skip rules 1–3.
|
|
109
|
+
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
|
+
}
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// kind:"dynamic" (or omitted) REQUIRES a non-empty criteria.
|
|
121
|
+
if (criteria === undefined) {
|
|
122
|
+
ctx.addIssue({
|
|
123
|
+
code: "custom",
|
|
124
|
+
path: ["criteria"],
|
|
125
|
+
message: 'kind:"dynamic" buckets require a non-empty `criteria`.',
|
|
126
|
+
});
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const leaves = Array.from(walkConditions(criteria));
|
|
131
|
+
|
|
132
|
+
// Rule 1: at-least-one-positive-condition (dynamic buckets only).
|
|
133
|
+
if (leaves.length > 0 && leaves.every(isNegativeLeaf)) {
|
|
134
|
+
ctx.addIssue({
|
|
135
|
+
code: "custom",
|
|
136
|
+
path: ["criteria"],
|
|
137
|
+
message:
|
|
138
|
+
"Dynamic buckets must contain at least one positive condition; " +
|
|
139
|
+
"pure-negation criteria are degenerate/unbounded.",
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const leaf of leaves) {
|
|
144
|
+
// Rule 2: reserved-prefix rejection on EventCondition.eventName.
|
|
145
|
+
if (leaf.type === "event" && leaf.eventName.startsWith("bucket:")) {
|
|
146
|
+
ctx.addIssue({
|
|
147
|
+
code: "custom",
|
|
148
|
+
path: ["criteria"],
|
|
149
|
+
message:
|
|
150
|
+
"criteria must not reference a reserved `bucket:*` event name " +
|
|
151
|
+
`(found "${leaf.eventName}").`,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Rule 3: email_engagement forbidden anywhere in v1.
|
|
156
|
+
if (leaf.type === "email_engagement") {
|
|
157
|
+
ctx.addIssue({
|
|
158
|
+
code: "custom",
|
|
159
|
+
path: ["criteria"],
|
|
160
|
+
message:
|
|
161
|
+
"email_engagement conditions are not allowed in bucket criteria " +
|
|
162
|
+
"in v1.",
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
});
|
package/src/schemas/index.ts
CHANGED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { DurationObject } from "../duration.js";
|
|
2
|
+
import type { ConditionEval } from "./conditions.js";
|
|
3
|
+
|
|
4
|
+
export interface BucketMeta {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
/** Static load-time flag (guard #1), mirrors JourneyMeta.enabled. */
|
|
9
|
+
enabled: boolean;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Discriminator, declared NOW for forward-compat even though "manual" ships in
|
|
13
|
+
* Phase 4. "dynamic" (default) = membership auto-recomputed from `criteria`;
|
|
14
|
+
* "manual" = membership mutated only by explicit API/import, NO criteria,
|
|
15
|
+
* skipped by checkBucketMembership and the reconcile cron (early-continue, the
|
|
16
|
+
* Laudspeaker pattern). Declaring it up front keeps Phase 4 genuinely additive
|
|
17
|
+
* — no breaking change to BucketMeta later. Default "dynamic".
|
|
18
|
+
*/
|
|
19
|
+
kind?: "dynamic" | "manual";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Membership predicate — the existing 4-type condition engine
|
|
23
|
+
* (packages/core/src/conditions/evaluate.ts). Inclusion AND exclusion come
|
|
24
|
+
* for free via neq / not_exists / not_opened and event check:"not_exists".
|
|
25
|
+
* REQUIRED for kind:"dynamic" (omit/empty for kind:"manual"). Dynamic buckets
|
|
26
|
+
* MUST contain at least one positive condition (validated; pure-negation
|
|
27
|
+
* buckets are degenerate/unbounded — the Customer.io rule). The
|
|
28
|
+
* at-least-one-positive refine applies to dynamic buckets only.
|
|
29
|
+
* NOTE: criteria MUST NOT reference a reserved `bucket:*` event name in any
|
|
30
|
+
* EventCondition.eventName (rejected at registration — see 4.2), so transition
|
|
31
|
+
* rows can never satisfy a bucket predicate.
|
|
32
|
+
*/
|
|
33
|
+
criteria?: ConditionEval;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Re-entry policy for EMITTED join events (maps onto checkEntryLimit
|
|
37
|
+
* semantics). "once" = emit bucket:entered once ever; "once_per_period" =
|
|
38
|
+
* re-emit only after a prior leave + period elapses; "unlimited" = always.
|
|
39
|
+
* Default "unlimited".
|
|
40
|
+
*/
|
|
41
|
+
reentry?: "once" | "once_per_period" | "unlimited";
|
|
42
|
+
reentryPeriod?: DurationObject;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Anti-flap: suppress bucket:left until membership has existed at least this
|
|
46
|
+
* long (debounce). Guards journeys from re-enroll spam on oscillation.
|
|
47
|
+
*/
|
|
48
|
+
minDwell?: DurationObject;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Maximum dwell — an UNCONDITIONAL membership TTL. `maxDwell` after the user
|
|
52
|
+
* joined, the reconcile cron force-leaves them REGARDLESS of whether the
|
|
53
|
+
* criteria still match (contrast `within`, which is criteria-driven, and
|
|
54
|
+
* `minDwell`, which is a floor). Use it for time-boxed membership: "in this
|
|
55
|
+
* bucket for exactly N days, then out".
|
|
56
|
+
*
|
|
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
|
|
59
|
+
* out / cool off), or leave the default `"unlimited"` for a PERIODIC FLUSH
|
|
60
|
+
* (they re-join on their next qualifying event). Independent of `within` and
|
|
61
|
+
* `fastExpiry`; if `minDwell` is also set it must be <= `maxDwell` (validated).
|
|
62
|
+
* Enforced by the reconcile cron, so the exit lands within the reconcile
|
|
63
|
+
* cadence (default 5m), not to-the-second.
|
|
64
|
+
*/
|
|
65
|
+
maxDwell?: DurationObject;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Reconciliation knobs.
|
|
69
|
+
* timeBased: criteria contain an event `within` window a clock can expire —
|
|
70
|
+
* the ONLY kind the cron sweep touches (candidate narrowing). Inferred from
|
|
71
|
+
* a criteria walk if omitted; an explicit value overrides.
|
|
72
|
+
* reconcileEvery: advisory cadence surfaced in Studio (the single engine-wide
|
|
73
|
+
* 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
|
|
76
|
+
* O(active members)).
|
|
77
|
+
* fastExpiry: opt-in per-user durable timer for sub-second absence-leave on
|
|
78
|
+
* latency-critical buckets (Approach A graft). The cron remains the
|
|
79
|
+
* authoritative backstop. Default false.
|
|
80
|
+
*/
|
|
81
|
+
timeBased?: boolean;
|
|
82
|
+
reconcileEvery?: DurationObject;
|
|
83
|
+
reconcileJoins?: boolean;
|
|
84
|
+
fastExpiry?: boolean;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* PostHog person-property sync (Section 12). Off by default. When set, on
|
|
88
|
+
* join/leave the engine $set/$unset a boolean person property keyed by
|
|
89
|
+
* `postHogPropertyKey` (default `hogsend_bucket_<id>`).
|
|
90
|
+
*/
|
|
91
|
+
syncToPostHog?: boolean;
|
|
92
|
+
postHogPropertyKey?: string;
|
|
93
|
+
}
|
package/src/types/index.ts
CHANGED