@hogsend/core 0.0.1 → 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 +9 -3
- package/src/index.ts +8 -2
- package/src/registry/bucket.ts +119 -0
- package/src/registry/index.ts +6 -0
- package/src/schedule/index.ts +10 -0
- package/src/schedule/resolvers.ts +136 -0
- package/src/schedule/schedule.test.ts +283 -0
- package/src/schedule/time.ts +53 -0
- package/src/schedule/tz.ts +31 -0
- package/src/schedule/window.ts +91 -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/src/types/journey-context.ts +53 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/core",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"./types": "./src/types/index.ts",
|
|
17
17
|
"./registry": "./src/registry/index.ts",
|
|
18
18
|
"./conditions": "./src/conditions/index.ts",
|
|
19
|
+
"./schedule": "./src/schedule/index.ts",
|
|
19
20
|
"./schemas": "./src/schemas/index.ts"
|
|
20
21
|
},
|
|
21
22
|
"files": [
|
|
@@ -26,15 +27,20 @@
|
|
|
26
27
|
"access": "public"
|
|
27
28
|
},
|
|
28
29
|
"dependencies": {
|
|
30
|
+
"@js-temporal/polyfill": "^0.5.1",
|
|
29
31
|
"drizzle-orm": "^0.45.2",
|
|
32
|
+
"iana-db-timezones": "^0.3.0",
|
|
30
33
|
"zod": "^4.4.3",
|
|
31
|
-
"@hogsend/db": "^0.0
|
|
34
|
+
"@hogsend/db": "^0.2.0"
|
|
32
35
|
},
|
|
33
36
|
"devDependencies": {
|
|
34
37
|
"@types/node": "latest",
|
|
38
|
+
"vitest": "^4.1.8",
|
|
35
39
|
"@repo/typescript-config": "0.0.0"
|
|
36
40
|
},
|
|
37
41
|
"scripts": {
|
|
38
|
-
"check-types": "tsc --noEmit"
|
|
42
|
+
"check-types": "tsc --noEmit",
|
|
43
|
+
"test": "vitest run",
|
|
44
|
+
"test:watch": "vitest"
|
|
39
45
|
}
|
|
40
46
|
}
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,12 @@ export {
|
|
|
11
11
|
hours,
|
|
12
12
|
minutes,
|
|
13
13
|
} from "./duration.js";
|
|
14
|
-
export {
|
|
15
|
-
|
|
14
|
+
export {
|
|
15
|
+
BucketRegistry,
|
|
16
|
+
collectEventNames,
|
|
17
|
+
collectPropertyNames,
|
|
18
|
+
JourneyRegistry,
|
|
19
|
+
} from "./registry/index.js";
|
|
20
|
+
export * from "./schedule/index.js";
|
|
21
|
+
export { bucketMetaSchema, journeyMetaSchema } from "./schemas/index.js";
|
|
16
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,10 @@
|
|
|
1
|
+
export {
|
|
2
|
+
resolveAfter,
|
|
3
|
+
resolveNextLocalTime,
|
|
4
|
+
resolveNextWeekday,
|
|
5
|
+
resolveTomorrow,
|
|
6
|
+
type ScheduleOptions,
|
|
7
|
+
} from "./resolvers.js";
|
|
8
|
+
export { parseTimeOfDay, weekdayToIso } from "./time.js";
|
|
9
|
+
export { isValidTimeZone, type TimeZone } from "./tz.js";
|
|
10
|
+
export { clampToWindow, type SendWindow } from "./window.js";
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { Temporal } from "@js-temporal/polyfill";
|
|
2
|
+
import type { DurationObject } from "../duration.js";
|
|
3
|
+
import { durationToMs } from "../duration.js";
|
|
4
|
+
import type { IfPast, Weekday } from "../types/journey-context.js";
|
|
5
|
+
import { parseTimeOfDay, weekdayToIso } from "./time.js";
|
|
6
|
+
import { clampToWindow, type SendWindow } from "./window.js";
|
|
7
|
+
|
|
8
|
+
export interface ScheduleOptions {
|
|
9
|
+
/** Resolved IANA timezone, e.g. "America/New_York". */
|
|
10
|
+
timezone: string;
|
|
11
|
+
/** Explicit "current" instant — no ambient `Date.now()`. */
|
|
12
|
+
now: Date;
|
|
13
|
+
/** Optional quiet-hours window in the same tz. */
|
|
14
|
+
window?: SendWindow;
|
|
15
|
+
/** How to treat a naive resolved instant that is already <= now. */
|
|
16
|
+
ifPast?: IfPast;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Convert a `ZonedDateTime` to a `Date`, applying the `ifPast` policy and the
|
|
21
|
+
* optional send window. This is the shared tail of every resolver:
|
|
22
|
+
*
|
|
23
|
+
* 1. `ifPast` — `"next"` (default) rolls the instant forward by `rollDays` when
|
|
24
|
+
* it is at/before `now` (never fewer than 1 day, so a snapped past instant
|
|
25
|
+
* always lands in the future); `"now"` clamps it to the current instant.
|
|
26
|
+
* 2. `clampToWindow` — if a window is configured, the final instant is mapped
|
|
27
|
+
* into the open window (the LAST step, after `ifPast`).
|
|
28
|
+
*/
|
|
29
|
+
function finalize(
|
|
30
|
+
candidate: Temporal.ZonedDateTime,
|
|
31
|
+
opts: ScheduleOptions,
|
|
32
|
+
rollDays: number,
|
|
33
|
+
): Date {
|
|
34
|
+
const nowMs = opts.now.getTime();
|
|
35
|
+
let resolved = candidate;
|
|
36
|
+
|
|
37
|
+
if (resolved.epochMilliseconds <= nowMs) {
|
|
38
|
+
if ((opts.ifPast ?? "next") === "now") {
|
|
39
|
+
resolved = Temporal.Instant.fromEpochMilliseconds(
|
|
40
|
+
nowMs,
|
|
41
|
+
).toZonedDateTimeISO(opts.timezone);
|
|
42
|
+
} else {
|
|
43
|
+
// ifPast "next": always roll forward. Resolvers that snap to an
|
|
44
|
+
// arbitrary HH:mm (resolveAfter / resolveTomorrow) pass rollDays 0, so a
|
|
45
|
+
// snapped time landing at/before now must still advance at least one day
|
|
46
|
+
// — otherwise a past instant would be returned and fire immediately.
|
|
47
|
+
resolved = resolved.add({ days: Math.max(rollDays, 1) });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const instant = new Date(resolved.epochMilliseconds);
|
|
52
|
+
return opts.window
|
|
53
|
+
? clampToWindow(instant, opts.window, opts.timezone)
|
|
54
|
+
: instant;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Build the wall-clock `HH:mm` instant on `date` in `opts.timezone`.
|
|
59
|
+
*
|
|
60
|
+
* Disambiguation `"compatible"` (the JS-`Date` rule) is the documented choice:
|
|
61
|
+
* on a spring-forward gap it yields the post-gap instant ("first valid instant
|
|
62
|
+
* going forward"); on a fall-back overlap it yields the earlier occurrence.
|
|
63
|
+
*/
|
|
64
|
+
function atTime(
|
|
65
|
+
date: Temporal.PlainDate,
|
|
66
|
+
time: string,
|
|
67
|
+
timezone: string,
|
|
68
|
+
): Temporal.ZonedDateTime {
|
|
69
|
+
const { hour, minute } = parseTimeOfDay(time);
|
|
70
|
+
return date
|
|
71
|
+
.toPlainDateTime(Temporal.PlainTime.from({ hour, minute }))
|
|
72
|
+
.toZonedDateTime(timezone, { disambiguation: "compatible" });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function nowZoned(opts: ScheduleOptions): Temporal.ZonedDateTime {
|
|
76
|
+
return Temporal.Instant.fromEpochMilliseconds(
|
|
77
|
+
opts.now.getTime(),
|
|
78
|
+
).toZonedDateTimeISO(opts.timezone);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Next occurrence of `HH:mm` local: today if still in the future (per
|
|
83
|
+
* `ifPast`), otherwise tomorrow.
|
|
84
|
+
*/
|
|
85
|
+
export function resolveNextLocalTime(
|
|
86
|
+
time: string,
|
|
87
|
+
opts: ScheduleOptions,
|
|
88
|
+
): Date {
|
|
89
|
+
const candidate = atTime(nowZoned(opts).toPlainDate(), time, opts.timezone);
|
|
90
|
+
return finalize(candidate, opts, 1);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Upcoming `weekday` at `HH:mm` local. If today IS the weekday and the time is
|
|
95
|
+
* still in the future, returns today; otherwise the next matching weekday
|
|
96
|
+
* (1..7 days ahead).
|
|
97
|
+
*/
|
|
98
|
+
export function resolveNextWeekday(
|
|
99
|
+
weekday: Weekday,
|
|
100
|
+
time: string,
|
|
101
|
+
opts: ScheduleOptions,
|
|
102
|
+
): Date {
|
|
103
|
+
const targetIso = weekdayToIso(weekday);
|
|
104
|
+
const today = nowZoned(opts).toPlainDate();
|
|
105
|
+
const delta = (targetIso - today.dayOfWeek + 7) % 7;
|
|
106
|
+
const candidate = atTime(today.add({ days: delta }), time, opts.timezone);
|
|
107
|
+
// If the chosen day is today (delta === 0) but the time already passed, roll
|
|
108
|
+
// a full week forward rather than a single day.
|
|
109
|
+
return finalize(candidate, opts, 7);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Tomorrow (now + 1 calendar day in tz) at `HH:mm` local. */
|
|
113
|
+
export function resolveTomorrow(time: string, opts: ScheduleOptions): Date {
|
|
114
|
+
const tomorrow = nowZoned(opts).toPlainDate().add({ days: 1 });
|
|
115
|
+
const candidate = atTime(tomorrow, time, opts.timezone);
|
|
116
|
+
// Tomorrow at HH:mm is already in the future; rollDays 0 means finalize only
|
|
117
|
+
// intervenes for ifPast="now" or the defensive 1-day roll if it ever lands
|
|
118
|
+
// at/before now.
|
|
119
|
+
return finalize(candidate, opts, 0);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* `now` + `duration`, then snapped to `HH:mm` local on the resulting calendar
|
|
124
|
+
* day.
|
|
125
|
+
*/
|
|
126
|
+
export function resolveAfter(
|
|
127
|
+
duration: DurationObject,
|
|
128
|
+
time: string,
|
|
129
|
+
opts: ScheduleOptions,
|
|
130
|
+
): Date {
|
|
131
|
+
const shifted = Temporal.Instant.fromEpochMilliseconds(
|
|
132
|
+
opts.now.getTime() + durationToMs(duration),
|
|
133
|
+
).toZonedDateTimeISO(opts.timezone);
|
|
134
|
+
const candidate = atTime(shifted.toPlainDate(), time, opts.timezone);
|
|
135
|
+
return finalize(candidate, opts, 0);
|
|
136
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { Temporal } from "@js-temporal/polyfill";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { days, hours } from "../duration.js";
|
|
4
|
+
import {
|
|
5
|
+
clampToWindow,
|
|
6
|
+
isValidTimeZone,
|
|
7
|
+
parseTimeOfDay,
|
|
8
|
+
resolveAfter,
|
|
9
|
+
resolveNextLocalTime,
|
|
10
|
+
resolveNextWeekday,
|
|
11
|
+
resolveTomorrow,
|
|
12
|
+
weekdayToIso,
|
|
13
|
+
} from "./index.js";
|
|
14
|
+
|
|
15
|
+
const NY = "America/New_York";
|
|
16
|
+
|
|
17
|
+
/** Build a Date from a wall-clock time in a tz (compatible disambiguation). */
|
|
18
|
+
function at(
|
|
19
|
+
tz: string,
|
|
20
|
+
y: number,
|
|
21
|
+
m: number,
|
|
22
|
+
d: number,
|
|
23
|
+
h: number,
|
|
24
|
+
min: number,
|
|
25
|
+
): Date {
|
|
26
|
+
const zdt = Temporal.PlainDateTime.from({
|
|
27
|
+
year: y,
|
|
28
|
+
month: m,
|
|
29
|
+
day: d,
|
|
30
|
+
hour: h,
|
|
31
|
+
minute: min,
|
|
32
|
+
}).toZonedDateTime(tz, { disambiguation: "compatible" });
|
|
33
|
+
return new Date(zdt.epochMilliseconds);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** UTC offset string ("-04:00") of a Date in a tz. */
|
|
37
|
+
function offsetOf(date: Date, tz: string): string {
|
|
38
|
+
return Temporal.Instant.fromEpochMilliseconds(
|
|
39
|
+
date.getTime(),
|
|
40
|
+
).toZonedDateTimeISO(tz).offset;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Local wall-clock "HH:mm" of a Date in a tz. */
|
|
44
|
+
function wall(date: Date, tz: string): string {
|
|
45
|
+
const zdt = Temporal.Instant.fromEpochMilliseconds(
|
|
46
|
+
date.getTime(),
|
|
47
|
+
).toZonedDateTimeISO(tz);
|
|
48
|
+
return `${String(zdt.hour).padStart(2, "0")}:${String(zdt.minute).padStart(
|
|
49
|
+
2,
|
|
50
|
+
"0",
|
|
51
|
+
)}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe("parseTimeOfDay", () => {
|
|
55
|
+
it("parses HH:mm", () => {
|
|
56
|
+
expect(parseTimeOfDay("08:30")).toEqual({ hour: 8, minute: 30 });
|
|
57
|
+
expect(parseTimeOfDay("23:59")).toEqual({ hour: 23, minute: 59 });
|
|
58
|
+
});
|
|
59
|
+
it("throws on malformed / out-of-range", () => {
|
|
60
|
+
expect(() => parseTimeOfDay("8h")).toThrow();
|
|
61
|
+
expect(() => parseTimeOfDay("24:00")).toThrow();
|
|
62
|
+
expect(() => parseTimeOfDay("12:60")).toThrow();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("weekdayToIso", () => {
|
|
67
|
+
it("maps short and full names to the same ISO weekday", () => {
|
|
68
|
+
expect(weekdayToIso("tue")).toBe(2);
|
|
69
|
+
expect(weekdayToIso("tuesday")).toBe(2);
|
|
70
|
+
expect(weekdayToIso("sun")).toBe(7);
|
|
71
|
+
expect(weekdayToIso("monday")).toBe(1);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("resolveNextLocalTime", () => {
|
|
76
|
+
it("returns same-day when time is still future", () => {
|
|
77
|
+
const now = at(NY, 2026, 6, 1, 10, 0);
|
|
78
|
+
const got = resolveNextLocalTime("14:00", { timezone: NY, now });
|
|
79
|
+
expect(got).toEqual(at(NY, 2026, 6, 1, 14, 0));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("rolls to next day when past (ifPast default 'next')", () => {
|
|
83
|
+
const now = at(NY, 2026, 6, 1, 15, 0);
|
|
84
|
+
const got = resolveNextLocalTime("14:00", { timezone: NY, now });
|
|
85
|
+
expect(got).toEqual(at(NY, 2026, 6, 2, 14, 0));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("ifPast 'now' clamps to the current instant", () => {
|
|
89
|
+
const now = at(NY, 2026, 6, 1, 15, 0);
|
|
90
|
+
const got = resolveNextLocalTime("14:00", {
|
|
91
|
+
timezone: NY,
|
|
92
|
+
now,
|
|
93
|
+
ifPast: "now",
|
|
94
|
+
});
|
|
95
|
+
expect(got.getTime()).toBe(now.getTime());
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("exact equality (now == target) rolls forward under 'next'", () => {
|
|
99
|
+
const now = at(NY, 2026, 6, 1, 14, 0);
|
|
100
|
+
const got = resolveNextLocalTime("14:00", { timezone: NY, now });
|
|
101
|
+
expect(got).toEqual(at(NY, 2026, 6, 2, 14, 0));
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("resolveNextWeekday", () => {
|
|
106
|
+
it("short/full names produce identical instants", () => {
|
|
107
|
+
const now = at(NY, 2026, 6, 1, 9, 0); // Monday
|
|
108
|
+
const a = resolveNextWeekday("tue", "08:00", { timezone: NY, now });
|
|
109
|
+
const b = resolveNextWeekday("tuesday", "08:00", { timezone: NY, now });
|
|
110
|
+
expect(a).toEqual(b);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("Mon → next Tuesday is tomorrow", () => {
|
|
114
|
+
const now = at(NY, 2026, 6, 1, 9, 0); // Monday 2026-06-01
|
|
115
|
+
const got = resolveNextWeekday("tuesday", "08:00", { timezone: NY, now });
|
|
116
|
+
expect(got).toEqual(at(NY, 2026, 6, 2, 8, 0));
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("Tue 06:00 → today 08:00 (same-day future)", () => {
|
|
120
|
+
const now = at(NY, 2026, 6, 2, 6, 0); // Tuesday
|
|
121
|
+
const got = resolveNextWeekday("tuesday", "08:00", { timezone: NY, now });
|
|
122
|
+
expect(got).toEqual(at(NY, 2026, 6, 2, 8, 0));
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("Tue 09:00 → next Tuesday (7 days)", () => {
|
|
126
|
+
const now = at(NY, 2026, 6, 2, 9, 0); // Tuesday
|
|
127
|
+
const got = resolveNextWeekday("tuesday", "08:00", { timezone: NY, now });
|
|
128
|
+
expect(got).toEqual(at(NY, 2026, 6, 9, 8, 0));
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("Sunday → next Monday is tomorrow (week wrap)", () => {
|
|
132
|
+
const now = at(NY, 2026, 6, 7, 12, 0); // Sunday 2026-06-07
|
|
133
|
+
const got = resolveNextWeekday("monday", "08:00", { timezone: NY, now });
|
|
134
|
+
expect(got).toEqual(at(NY, 2026, 6, 8, 8, 0));
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("resolveTomorrow", () => {
|
|
139
|
+
it("returns tomorrow at HH:mm", () => {
|
|
140
|
+
const now = at(NY, 2026, 6, 1, 10, 0);
|
|
141
|
+
const got = resolveTomorrow("08:00", { timezone: NY, now });
|
|
142
|
+
expect(got).toEqual(at(NY, 2026, 6, 2, 8, 0));
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("resolveAfter", () => {
|
|
147
|
+
it("now + 2 days snapped to 08:00 local", () => {
|
|
148
|
+
const now = at(NY, 2026, 6, 1, 10, 0);
|
|
149
|
+
const got = resolveAfter(days(2), "08:00", { timezone: NY, now });
|
|
150
|
+
expect(got).toEqual(at(NY, 2026, 6, 3, 8, 0));
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("in 1 hour crossing midnight snaps to next day's HH:mm", () => {
|
|
154
|
+
const now = at(NY, 2026, 6, 1, 23, 30);
|
|
155
|
+
const got = resolveAfter(hours(1), "08:00", { timezone: NY, now });
|
|
156
|
+
// now + 1h => 2026-06-02 00:30 local => snapped to 08:00 that day.
|
|
157
|
+
expect(got).toEqual(at(NY, 2026, 6, 2, 8, 0));
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("snapped HH:mm earlier than now on the same day rolls forward (not past)", () => {
|
|
161
|
+
// now 09:00, +1h => 10:00 same day, but .at('08:00') pulls back to 08:00
|
|
162
|
+
// which is already in the past. Must roll to tomorrow's 08:00, never < now.
|
|
163
|
+
const now = at(NY, 2026, 6, 1, 9, 0);
|
|
164
|
+
const got = resolveAfter(hours(1), "08:00", { timezone: NY, now });
|
|
165
|
+
expect(got.getTime()).toBeGreaterThan(now.getTime());
|
|
166
|
+
expect(got).toEqual(at(NY, 2026, 6, 2, 8, 0));
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("snapped HH:mm earlier than now with ifPast 'now' clamps to current instant", () => {
|
|
170
|
+
const now = at(NY, 2026, 6, 1, 9, 0);
|
|
171
|
+
const got = resolveAfter(hours(1), "08:00", {
|
|
172
|
+
timezone: NY,
|
|
173
|
+
now,
|
|
174
|
+
ifPast: "now",
|
|
175
|
+
});
|
|
176
|
+
expect(got.getTime()).toBe(now.getTime());
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("DST handling (America/New_York)", () => {
|
|
181
|
+
it("spring-forward gap (2026-03-08 02:30) picks post-gap EDT instant", () => {
|
|
182
|
+
// Clocks jump 02:00 -> 03:00; 02:30 does not exist.
|
|
183
|
+
const now = at(NY, 2026, 3, 8, 0, 0);
|
|
184
|
+
const got = resolveNextLocalTime("02:30", { timezone: NY, now });
|
|
185
|
+
// "compatible" => later instant; resulting offset is EDT (-04:00).
|
|
186
|
+
expect(offsetOf(got, NY)).toBe("-04:00");
|
|
187
|
+
expect(wall(got, NY)).toBe("03:30");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("fall-back overlap (2026-11-01 01:30) picks earlier (EDT) instant", () => {
|
|
191
|
+
const now = at(NY, 2026, 11, 1, 0, 0);
|
|
192
|
+
const got = resolveNextLocalTime("01:30", { timezone: NY, now });
|
|
193
|
+
// The earlier of the two 01:30s is still EDT (-04:00).
|
|
194
|
+
expect(offsetOf(got, NY)).toBe("-04:00");
|
|
195
|
+
expect(wall(got, NY)).toBe("01:30");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("resolveTomorrow across spring-forward uses calendar add (not +24h)", () => {
|
|
199
|
+
const now = at(NY, 2026, 3, 7, 23, 0); // EST
|
|
200
|
+
const got = resolveTomorrow("08:00", { timezone: NY, now });
|
|
201
|
+
expect(wall(got, NY)).toBe("08:00");
|
|
202
|
+
expect(offsetOf(got, NY)).toBe("-04:00"); // 2026-03-08 is EDT
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe("clampToWindow", () => {
|
|
207
|
+
const win = { start: "09:00", end: "17:00" };
|
|
208
|
+
|
|
209
|
+
it("inside window is unchanged", () => {
|
|
210
|
+
const inst = at(NY, 2026, 6, 1, 12, 0);
|
|
211
|
+
expect(clampToWindow(inst, win, NY)).toEqual(inst);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("before open snaps to today's open", () => {
|
|
215
|
+
const inst = at(NY, 2026, 6, 1, 7, 0);
|
|
216
|
+
expect(clampToWindow(inst, win, NY)).toEqual(at(NY, 2026, 6, 1, 9, 0));
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("after close snaps to tomorrow's open", () => {
|
|
220
|
+
const inst = at(NY, 2026, 6, 1, 19, 0);
|
|
221
|
+
expect(clampToWindow(inst, win, NY)).toEqual(at(NY, 2026, 6, 2, 9, 0));
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("exactly at close (17:00) is treated as after-close", () => {
|
|
225
|
+
const inst = at(NY, 2026, 6, 1, 17, 0);
|
|
226
|
+
expect(clampToWindow(inst, win, NY)).toEqual(at(NY, 2026, 6, 2, 9, 0));
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("exactly at open (09:00) is unchanged (inclusive)", () => {
|
|
230
|
+
const inst = at(NY, 2026, 6, 1, 9, 0);
|
|
231
|
+
expect(clampToWindow(inst, win, NY)).toEqual(inst);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("start === end is always open", () => {
|
|
235
|
+
const inst = at(NY, 2026, 6, 1, 3, 0);
|
|
236
|
+
const allDay = { start: "00:00", end: "00:00" };
|
|
237
|
+
expect(clampToWindow(inst, allDay, NY)).toEqual(inst);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe("overnight window 22:00-06:00", () => {
|
|
241
|
+
const overnight = { start: "22:00", end: "06:00" };
|
|
242
|
+
it("23:00 is in the open tail", () => {
|
|
243
|
+
const inst = at(NY, 2026, 6, 1, 23, 0);
|
|
244
|
+
expect(clampToWindow(inst, overnight, NY)).toEqual(inst);
|
|
245
|
+
});
|
|
246
|
+
it("03:00 is in the early-morning open", () => {
|
|
247
|
+
const inst = at(NY, 2026, 6, 1, 3, 0);
|
|
248
|
+
expect(clampToWindow(inst, overnight, NY)).toEqual(inst);
|
|
249
|
+
});
|
|
250
|
+
it("12:00 quiet gap snaps forward to tonight's open", () => {
|
|
251
|
+
const inst = at(NY, 2026, 6, 1, 12, 0);
|
|
252
|
+
expect(clampToWindow(inst, overnight, NY)).toEqual(
|
|
253
|
+
at(NY, 2026, 6, 1, 22, 0),
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("clamp into next-open via resolver window option", () => {
|
|
259
|
+
// 19:00 local resolved with a 09:00-17:00 window => next day 09:00.
|
|
260
|
+
const now = at(NY, 2026, 6, 1, 10, 0);
|
|
261
|
+
const got = resolveNextLocalTime("19:00", {
|
|
262
|
+
timezone: NY,
|
|
263
|
+
now,
|
|
264
|
+
window: win,
|
|
265
|
+
});
|
|
266
|
+
expect(got).toEqual(at(NY, 2026, 6, 2, 9, 0));
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe("isValidTimeZone", () => {
|
|
271
|
+
it("accepts valid IANA zones", () => {
|
|
272
|
+
expect(isValidTimeZone("America/New_York")).toBe(true);
|
|
273
|
+
expect(isValidTimeZone("UTC")).toBe(true);
|
|
274
|
+
expect(isValidTimeZone("Europe/London")).toBe(true);
|
|
275
|
+
});
|
|
276
|
+
it("rejects invalid / empty without throwing", () => {
|
|
277
|
+
expect(isValidTimeZone("Not/AZone")).toBe(false);
|
|
278
|
+
expect(isValidTimeZone("")).toBe(false);
|
|
279
|
+
expect(isValidTimeZone("garbage")).toBe(false);
|
|
280
|
+
// @ts-expect-error testing non-string robustness
|
|
281
|
+
expect(isValidTimeZone(null)).toBe(false);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Weekday } from "../types/journey-context.js";
|
|
2
|
+
|
|
3
|
+
export type { IfPast, Weekday } from "../types/journey-context.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Map of accepted weekday names (short + full, lowercase) to ISO weekday
|
|
7
|
+
* numbers (Monday = 1 … Sunday = 7), matching `Temporal.PlainDate#dayOfWeek`.
|
|
8
|
+
*/
|
|
9
|
+
const WEEKDAY_TO_ISO: Record<Weekday, number> = {
|
|
10
|
+
mon: 1,
|
|
11
|
+
monday: 1,
|
|
12
|
+
tue: 2,
|
|
13
|
+
tuesday: 2,
|
|
14
|
+
wed: 3,
|
|
15
|
+
wednesday: 3,
|
|
16
|
+
thu: 4,
|
|
17
|
+
thursday: 4,
|
|
18
|
+
fri: 5,
|
|
19
|
+
friday: 5,
|
|
20
|
+
sat: 6,
|
|
21
|
+
saturday: 6,
|
|
22
|
+
sun: 7,
|
|
23
|
+
sunday: 7,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve a {@link Weekday} name to its ISO weekday number (1..7). Throws on an
|
|
28
|
+
* unknown name — this is author error, not a runtime fall-through case.
|
|
29
|
+
*/
|
|
30
|
+
export function weekdayToIso(weekday: Weekday): number {
|
|
31
|
+
const iso = WEEKDAY_TO_ISO[weekday.toLowerCase() as Weekday];
|
|
32
|
+
if (iso === undefined) {
|
|
33
|
+
throw new TypeError(`Unknown weekday: "${weekday}"`);
|
|
34
|
+
}
|
|
35
|
+
return iso;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parse a `"HH:mm"` string into `{ hour, minute }`. Throws on malformed input
|
|
40
|
+
* (author error — the scheduling API takes a literal time-of-day string).
|
|
41
|
+
*/
|
|
42
|
+
export function parseTimeOfDay(time: string): { hour: number; minute: number } {
|
|
43
|
+
const match = /^(\d{1,2}):(\d{2})$/.exec(time.trim());
|
|
44
|
+
if (!match) {
|
|
45
|
+
throw new TypeError(`Invalid time-of-day (expected "HH:mm"): "${time}"`);
|
|
46
|
+
}
|
|
47
|
+
const hour = Number(match[1]);
|
|
48
|
+
const minute = Number(match[2]);
|
|
49
|
+
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
|
50
|
+
throw new TypeError(`Time-of-day out of range: "${time}"`);
|
|
51
|
+
}
|
|
52
|
+
return { hour, minute };
|
|
53
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Temporal } from "@js-temporal/polyfill";
|
|
2
|
+
import type { TimezoneCode } from "iana-db-timezones";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A valid IANA timezone identifier (e.g. `"America/New_York"`, `"Europe/London"`)
|
|
6
|
+
* — the full canonical + alias set, as a string-literal union. Sourced from
|
|
7
|
+
* `iana-db-timezones` (type-only import, erased at build). Use this for
|
|
8
|
+
* author-supplied timezones (`ctx.when.tz(...)`, client `defaults.timezone`) to
|
|
9
|
+
* get autocomplete and compile-time typo-catching. Timezones that arrive as
|
|
10
|
+
* runtime *data* (PostHog props, contact rows) stay plain `string` and are
|
|
11
|
+
* validated via {@link isValidTimeZone}.
|
|
12
|
+
*/
|
|
13
|
+
export type TimeZone = TimezoneCode;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* True if `tz` is a usable IANA timezone identifier. Probes the zone via
|
|
17
|
+
* Temporal inside a try/catch — it never throws, even on garbage input, so it
|
|
18
|
+
* is safe to use as a validity gate in a precedence chain.
|
|
19
|
+
*/
|
|
20
|
+
export function isValidTimeZone(tz: string): boolean {
|
|
21
|
+
if (typeof tz !== "string" || tz.length === 0) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
// `Temporal.Now.zonedDateTimeISO(tz)` throws RangeError on an unknown zone.
|
|
26
|
+
Temporal.Now.zonedDateTimeISO(tz);
|
|
27
|
+
return true;
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Temporal } from "@js-temporal/polyfill";
|
|
2
|
+
import { parseTimeOfDay } from "./time.js";
|
|
3
|
+
|
|
4
|
+
/** A quiet-hours / send window as wall-clock `"HH:mm"` edges in some tz. */
|
|
5
|
+
export interface SendWindow {
|
|
6
|
+
start: string;
|
|
7
|
+
end: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Build the absolute instant for a `PlainDate` at `HH:mm` in `timezone`.
|
|
12
|
+
*
|
|
13
|
+
* All wall-clock → instant conversions use disambiguation `"compatible"` (the
|
|
14
|
+
* JS-`Date`-equivalent rule): on a spring-forward gap it picks the later (post-
|
|
15
|
+
* gap) instant; on a fall-back overlap it picks the earlier occurrence. This
|
|
16
|
+
* keeps window edges DST-safe.
|
|
17
|
+
*/
|
|
18
|
+
function instantAt(
|
|
19
|
+
date: Temporal.PlainDate,
|
|
20
|
+
hour: number,
|
|
21
|
+
minute: number,
|
|
22
|
+
timezone: string,
|
|
23
|
+
): Temporal.ZonedDateTime {
|
|
24
|
+
return date
|
|
25
|
+
.toPlainDateTime(Temporal.PlainTime.from({ hour, minute }))
|
|
26
|
+
.toZonedDateTime(timezone, { disambiguation: "compatible" });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Map an instant into the open send window in `timezone`. No-op if the instant
|
|
31
|
+
* is already inside the window.
|
|
32
|
+
*
|
|
33
|
+
* - Normal window (`start < end`, e.g. `09:00`–`17:00`): open means
|
|
34
|
+
* `open <= instant < close`. Before open → today's open; at/after close →
|
|
35
|
+
* tomorrow's open.
|
|
36
|
+
* - Overnight window (`start > end`, e.g. `22:00`–`06:00`): wraps midnight.
|
|
37
|
+
* Open means `instant >= open` OR `instant < close`. In the quiet daytime
|
|
38
|
+
* gap → tonight's open.
|
|
39
|
+
* - `start === end` → always open (no clamp).
|
|
40
|
+
*
|
|
41
|
+
* "Next day" uses Temporal calendar arithmetic (`add({ days: 1 })`), so a 23/25-
|
|
42
|
+
* hour DST day still lands on the correct `HH:mm` wall-clock.
|
|
43
|
+
*/
|
|
44
|
+
export function clampToWindow(
|
|
45
|
+
instant: Date,
|
|
46
|
+
window: SendWindow,
|
|
47
|
+
timezone: string,
|
|
48
|
+
): Date {
|
|
49
|
+
const { hour: openH, minute: openM } = parseTimeOfDay(window.start);
|
|
50
|
+
const { hour: closeH, minute: closeM } = parseTimeOfDay(window.end);
|
|
51
|
+
|
|
52
|
+
// start === end → always open.
|
|
53
|
+
if (openH === closeH && openM === closeM) {
|
|
54
|
+
return instant;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const zdt = Temporal.Instant.fromEpochMilliseconds(
|
|
58
|
+
instant.getTime(),
|
|
59
|
+
).toZonedDateTimeISO(timezone);
|
|
60
|
+
const date = zdt.toPlainDate();
|
|
61
|
+
|
|
62
|
+
const todayOpen = instantAt(date, openH, openM, timezone);
|
|
63
|
+
const todayClose = instantAt(date, closeH, closeM, timezone);
|
|
64
|
+
const normal = openH < closeH || (openH === closeH && openM < closeM);
|
|
65
|
+
|
|
66
|
+
if (normal) {
|
|
67
|
+
if (Temporal.ZonedDateTime.compare(zdt, todayOpen) < 0) {
|
|
68
|
+
return new Date(todayOpen.epochMilliseconds);
|
|
69
|
+
}
|
|
70
|
+
if (Temporal.ZonedDateTime.compare(zdt, todayClose) >= 0) {
|
|
71
|
+
const tomorrowOpen = instantAt(
|
|
72
|
+
date.add({ days: 1 }),
|
|
73
|
+
openH,
|
|
74
|
+
openM,
|
|
75
|
+
timezone,
|
|
76
|
+
);
|
|
77
|
+
return new Date(tomorrowOpen.epochMilliseconds);
|
|
78
|
+
}
|
|
79
|
+
return instant;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Overnight window (start > end): close is in the morning, open in the evening.
|
|
83
|
+
if (Temporal.ZonedDateTime.compare(zdt, todayClose) < 0) {
|
|
84
|
+
return instant; // early-morning open tail
|
|
85
|
+
}
|
|
86
|
+
if (Temporal.ZonedDateTime.compare(zdt, todayOpen) >= 0) {
|
|
87
|
+
return instant; // late-evening open head
|
|
88
|
+
}
|
|
89
|
+
// Quiet daytime gap [close, open) → snap forward to tonight's open.
|
|
90
|
+
return new Date(todayOpen.epochMilliseconds);
|
|
91
|
+
}
|
|
@@ -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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DurationObject } from "../duration.js";
|
|
2
|
+
import type { TimeZone } from "../schedule/tz.js";
|
|
2
3
|
|
|
3
4
|
export interface SleepOptions {
|
|
4
5
|
duration: DurationObject;
|
|
@@ -10,6 +11,51 @@ export interface SleepResult {
|
|
|
10
11
|
resumedAt: string;
|
|
11
12
|
}
|
|
12
13
|
|
|
14
|
+
export interface SleepUntilOptions {
|
|
15
|
+
label?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type Weekday =
|
|
19
|
+
| "mon"
|
|
20
|
+
| "tue"
|
|
21
|
+
| "wed"
|
|
22
|
+
| "thu"
|
|
23
|
+
| "fri"
|
|
24
|
+
| "sat"
|
|
25
|
+
| "sun"
|
|
26
|
+
| "monday"
|
|
27
|
+
| "tuesday"
|
|
28
|
+
| "wednesday"
|
|
29
|
+
| "thursday"
|
|
30
|
+
| "friday"
|
|
31
|
+
| "saturday"
|
|
32
|
+
| "sunday";
|
|
33
|
+
|
|
34
|
+
/** How to treat a resolved instant that is already in the past. */
|
|
35
|
+
export type IfPast = "next" | "now";
|
|
36
|
+
|
|
37
|
+
export interface TimeOfDayBuilder {
|
|
38
|
+
/** Resolve to an absolute instant at `time` ("HH:mm") in the bound tz. */
|
|
39
|
+
at(time: string): Date;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface WhenBuilder {
|
|
43
|
+
/** Upcoming named weekday; chain `.at("HH:mm")`. */
|
|
44
|
+
next(weekday: Weekday): TimeOfDayBuilder;
|
|
45
|
+
/** Next occurrence of `time` local (today if future, else tomorrow). */
|
|
46
|
+
nextLocal(time: string): Date;
|
|
47
|
+
/** Tomorrow in the bound tz; chain `.at("HH:mm")`. */
|
|
48
|
+
tomorrow(): TimeOfDayBuilder;
|
|
49
|
+
/** `duration` from now, snapped to `.at("HH:mm")` on that day. */
|
|
50
|
+
in(duration: DurationObject): TimeOfDayBuilder;
|
|
51
|
+
/** Override the resolved user tz for this chain only. Returns a new builder. */
|
|
52
|
+
tz(timezone: TimeZone): WhenBuilder;
|
|
53
|
+
/** Override the default send window for this chain. Returns a new builder. */
|
|
54
|
+
window(start: string, end: string): WhenBuilder;
|
|
55
|
+
/** How to treat an already-past resolved time. Default "next". */
|
|
56
|
+
ifPast(strategy: IfPast): WhenBuilder;
|
|
57
|
+
}
|
|
58
|
+
|
|
13
59
|
export interface TriggerOptions {
|
|
14
60
|
event: string;
|
|
15
61
|
userId: string;
|
|
@@ -52,6 +98,13 @@ export interface EmailHistoryResult {
|
|
|
52
98
|
|
|
53
99
|
export interface JourneyContext {
|
|
54
100
|
sleep(opts: SleepOptions): Promise<SleepResult>;
|
|
101
|
+
|
|
102
|
+
/** Durable sleep until an absolute instant (`Date` or ISO string). */
|
|
103
|
+
sleepUntil(at: Date | string, opts?: SleepUntilOptions): Promise<SleepResult>;
|
|
104
|
+
|
|
105
|
+
/** Timezone-bound fluent scheduler. Always terminates in a `Date`. */
|
|
106
|
+
when: WhenBuilder;
|
|
107
|
+
|
|
55
108
|
checkpoint(label: string): Promise<void>;
|
|
56
109
|
trigger(opts: TriggerOptions): Promise<void>;
|
|
57
110
|
identify(properties: Record<string, unknown>): void;
|