@hogsend/engine 0.5.0 → 0.7.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 +6 -6
- package/src/buckets/bucket-access.ts +213 -0
- package/src/buckets/bucket-reactions.ts +225 -0
- package/src/buckets/check-membership.ts +35 -15
- package/src/buckets/define-bucket.ts +79 -8
- package/src/buckets/registry.ts +81 -0
- package/src/container.ts +69 -4
- package/src/env.ts +4 -0
- package/src/index.ts +27 -0
- package/src/journeys/journey-context.ts +5 -1
- package/src/lib/boot.ts +12 -2
- package/src/lib/bucket-emit.ts +49 -7
- package/src/lib/contacts.ts +1083 -18
- package/src/lib/email-service-types.ts +8 -0
- package/src/lib/ingestion.ts +63 -33
- package/src/lib/mailer.ts +1 -0
- package/src/lib/preferences.ts +106 -0
- package/src/lib/tracked.ts +159 -34
- package/src/lib/tracking-events.ts +1 -1
- package/src/lists/define-list.ts +81 -0
- package/src/lists/registry-singleton.ts +39 -0
- package/src/lists/registry.ts +95 -0
- package/src/middleware/api-key.ts +33 -7
- package/src/middleware/rate-limit.ts +73 -49
- package/src/routes/_shared.ts +30 -0
- package/src/routes/admin/api-keys.ts +1 -1
- package/src/routes/admin/buckets.ts +39 -9
- package/src/routes/admin/bulk.ts +7 -3
- package/src/routes/admin/contacts.ts +66 -57
- package/src/routes/admin/events.ts +65 -0
- package/src/routes/admin/journeys.ts +3 -1
- package/src/routes/admin/preferences.ts +2 -2
- package/src/routes/admin/reporting.ts +3 -3
- package/src/routes/admin/timeline.ts +5 -2
- package/src/routes/campaigns/index.ts +252 -0
- package/src/routes/contacts/index.ts +188 -0
- package/src/routes/email/preferences.ts +27 -3
- package/src/routes/email/unsubscribe.ts +7 -49
- package/src/routes/emails/index.ts +133 -0
- package/src/routes/events/index.ts +119 -0
- package/src/routes/index.ts +52 -2
- package/src/routes/lists/index.ts +222 -0
- package/src/worker.ts +25 -2
- package/src/workflows/bucket-backfill.ts +122 -22
- package/src/workflows/bucket-reconcile.ts +225 -12
- package/src/workflows/import-contacts.ts +28 -20
- package/src/workflows/send-campaign.ts +589 -0
- package/src/routes/ingest.ts +0 -71
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -37,11 +37,11 @@
|
|
|
37
37
|
"resend": "^6.12.3",
|
|
38
38
|
"winston": "^3.19.0",
|
|
39
39
|
"zod": "^4.4.3",
|
|
40
|
-
"@hogsend/core": "^0.
|
|
41
|
-
"@hogsend/db": "^0.
|
|
42
|
-
"@hogsend/email": "^0.
|
|
43
|
-
"@hogsend/plugin-posthog": "^0.
|
|
44
|
-
"@hogsend/plugin-resend": "^0.
|
|
40
|
+
"@hogsend/core": "^0.7.0",
|
|
41
|
+
"@hogsend/db": "^0.7.0",
|
|
42
|
+
"@hogsend/email": "^0.7.0",
|
|
43
|
+
"@hogsend/plugin-posthog": "^0.7.0",
|
|
44
|
+
"@hogsend/plugin-resend": "^0.7.0"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@types/node": "^22.15.3",
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { bucketMemberships, contacts, type Database } from "@hogsend/db";
|
|
2
|
+
import { and, count as countFn, eq, gt, isNull } from "drizzle-orm";
|
|
3
|
+
import { getDb } from "../lib/db.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Hard cap on a single page — mirrors the admin `listMembersRoute` `z.max(100)`.
|
|
7
|
+
* Member access is NEVER an unbounded array; even the iterator pages internally.
|
|
8
|
+
*/
|
|
9
|
+
const MAX_PAGE = 100;
|
|
10
|
+
/** Default page size when the caller does not specify a limit. */
|
|
11
|
+
const DEFAULT_PAGE = 50;
|
|
12
|
+
|
|
13
|
+
/** A serialized active-membership row returned by `members()` / the iterator. */
|
|
14
|
+
export interface BucketMemberRow {
|
|
15
|
+
id: string;
|
|
16
|
+
userId: string;
|
|
17
|
+
userEmail: string | null;
|
|
18
|
+
enteredAt: string;
|
|
19
|
+
entryCount: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Supabase-shaped paged result — no throw; failures land in `error`. */
|
|
23
|
+
export interface MembersResult {
|
|
24
|
+
data: BucketMemberRow[];
|
|
25
|
+
error: Error | null;
|
|
26
|
+
/**
|
|
27
|
+
* Per-call snapshot total (active, non-deleted, joined to a live contact).
|
|
28
|
+
* NOT a consistent paginated total — under churn it can drift page-to-page.
|
|
29
|
+
* The keyset cursor itself is churn-safe; use `count()` for one authoritative
|
|
30
|
+
* number.
|
|
31
|
+
*/
|
|
32
|
+
count: number | null;
|
|
33
|
+
/** Keyset continuation (last row `id`); `null` when the page is exhausted. */
|
|
34
|
+
cursor: string | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface BucketAccessor {
|
|
38
|
+
count(): Promise<{ data: number | null; error: Error | null }>;
|
|
39
|
+
has(userId: string): Promise<{ data: boolean; error: Error | null }>;
|
|
40
|
+
members(opts?: { limit?: number; cursor?: string }): Promise<MembersResult>;
|
|
41
|
+
membersIterator(opts?: {
|
|
42
|
+
pageSize?: number;
|
|
43
|
+
}): AsyncIterableIterator<BucketMemberRow>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function toError(err: unknown): Error {
|
|
47
|
+
return err instanceof Error ? err : new Error(String(err));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build the per-bucket member-access surface (`count`/`has`/`members`/iterator).
|
|
52
|
+
*
|
|
53
|
+
* The accessor is constructed at module load (`defineBucket` time), before any
|
|
54
|
+
* container exists, so it defaults to `getDb()` — the same singleton the
|
|
55
|
+
* desugared reaction journeys run on. To honor `overrides.db` in tests, the
|
|
56
|
+
* container re-binds the accessors with a `dbResolver` that returns its own
|
|
57
|
+
* `db`; the `getDb()` default bypasses the container.
|
|
58
|
+
*
|
|
59
|
+
* Every query `innerJoin`s `contacts` on `externalId` and filters
|
|
60
|
+
* `isNull(deletedAt)` on both tables — GDPR parity with every reconcile/admin
|
|
61
|
+
* query. No method throws (except the iterator on a page error); failures are
|
|
62
|
+
* carried in the result's `error`.
|
|
63
|
+
*/
|
|
64
|
+
export function createBucketAccessor(
|
|
65
|
+
bucketId: string,
|
|
66
|
+
dbResolver: () => Database = getDb,
|
|
67
|
+
): BucketAccessor {
|
|
68
|
+
async function count(): Promise<{
|
|
69
|
+
data: number | null;
|
|
70
|
+
error: Error | null;
|
|
71
|
+
}> {
|
|
72
|
+
try {
|
|
73
|
+
const db = dbResolver();
|
|
74
|
+
const rows = await db
|
|
75
|
+
.select({ value: countFn() })
|
|
76
|
+
.from(bucketMemberships)
|
|
77
|
+
.innerJoin(contacts, eq(contacts.externalId, bucketMemberships.userId))
|
|
78
|
+
.where(
|
|
79
|
+
and(
|
|
80
|
+
eq(bucketMemberships.bucketId, bucketId),
|
|
81
|
+
eq(bucketMemberships.status, "active"),
|
|
82
|
+
isNull(bucketMemberships.deletedAt),
|
|
83
|
+
isNull(contacts.deletedAt),
|
|
84
|
+
),
|
|
85
|
+
);
|
|
86
|
+
return { data: rows[0]?.value ?? 0, error: null };
|
|
87
|
+
} catch (err) {
|
|
88
|
+
return { data: null, error: toError(err) };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function has(
|
|
93
|
+
userId: string,
|
|
94
|
+
): Promise<{ data: boolean; error: Error | null }> {
|
|
95
|
+
try {
|
|
96
|
+
const db = dbResolver();
|
|
97
|
+
// O(1) probe on the partial active unique index (uq_user_bucket_active).
|
|
98
|
+
const rows = await db
|
|
99
|
+
.select({ id: bucketMemberships.id })
|
|
100
|
+
.from(bucketMemberships)
|
|
101
|
+
.innerJoin(contacts, eq(contacts.externalId, bucketMemberships.userId))
|
|
102
|
+
.where(
|
|
103
|
+
and(
|
|
104
|
+
eq(bucketMemberships.bucketId, bucketId),
|
|
105
|
+
eq(bucketMemberships.userId, userId),
|
|
106
|
+
eq(bucketMemberships.status, "active"),
|
|
107
|
+
isNull(bucketMemberships.deletedAt),
|
|
108
|
+
isNull(contacts.deletedAt),
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
.limit(1);
|
|
112
|
+
return { data: rows.length > 0, error: null };
|
|
113
|
+
} catch (err) {
|
|
114
|
+
return { data: false, error: toError(err) };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function members(opts?: {
|
|
119
|
+
limit?: number;
|
|
120
|
+
cursor?: string;
|
|
121
|
+
}): Promise<MembersResult> {
|
|
122
|
+
const limit = Math.min(opts?.limit ?? DEFAULT_PAGE, MAX_PAGE);
|
|
123
|
+
try {
|
|
124
|
+
const db = dbResolver();
|
|
125
|
+
const conditions = [
|
|
126
|
+
eq(bucketMemberships.bucketId, bucketId),
|
|
127
|
+
eq(bucketMemberships.status, "active"),
|
|
128
|
+
isNull(bucketMemberships.deletedAt),
|
|
129
|
+
isNull(contacts.deletedAt),
|
|
130
|
+
];
|
|
131
|
+
// Keyset cursor on `id` (UUID, unique, stable — NOT enteredAt, which ties
|
|
132
|
+
// on defaultNow). Opaque (UUID asc) order, not chronological.
|
|
133
|
+
if (opts?.cursor) {
|
|
134
|
+
conditions.push(gt(bucketMemberships.id, opts.cursor));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const [rows, totalRows] = await Promise.all([
|
|
138
|
+
db
|
|
139
|
+
.select({
|
|
140
|
+
id: bucketMemberships.id,
|
|
141
|
+
userId: bucketMemberships.userId,
|
|
142
|
+
userEmail: bucketMemberships.userEmail,
|
|
143
|
+
enteredAt: bucketMemberships.enteredAt,
|
|
144
|
+
entryCount: bucketMemberships.entryCount,
|
|
145
|
+
})
|
|
146
|
+
.from(bucketMemberships)
|
|
147
|
+
.innerJoin(
|
|
148
|
+
contacts,
|
|
149
|
+
eq(contacts.externalId, bucketMemberships.userId),
|
|
150
|
+
)
|
|
151
|
+
.where(and(...conditions))
|
|
152
|
+
.orderBy(bucketMemberships.id)
|
|
153
|
+
// +1 peek to detect a next page.
|
|
154
|
+
.limit(limit + 1),
|
|
155
|
+
db
|
|
156
|
+
.select({ value: countFn() })
|
|
157
|
+
.from(bucketMemberships)
|
|
158
|
+
.innerJoin(
|
|
159
|
+
contacts,
|
|
160
|
+
eq(contacts.externalId, bucketMemberships.userId),
|
|
161
|
+
)
|
|
162
|
+
.where(
|
|
163
|
+
and(
|
|
164
|
+
eq(bucketMemberships.bucketId, bucketId),
|
|
165
|
+
eq(bucketMemberships.status, "active"),
|
|
166
|
+
isNull(bucketMemberships.deletedAt),
|
|
167
|
+
isNull(contacts.deletedAt),
|
|
168
|
+
),
|
|
169
|
+
),
|
|
170
|
+
]);
|
|
171
|
+
|
|
172
|
+
const hasMore = rows.length > limit;
|
|
173
|
+
const page = hasMore ? rows.slice(0, limit) : rows;
|
|
174
|
+
const data: BucketMemberRow[] = page.map((r) => ({
|
|
175
|
+
id: r.id,
|
|
176
|
+
userId: r.userId,
|
|
177
|
+
userEmail: r.userEmail,
|
|
178
|
+
enteredAt: r.enteredAt.toISOString(),
|
|
179
|
+
entryCount: r.entryCount,
|
|
180
|
+
}));
|
|
181
|
+
const cursor = hasMore ? (page[page.length - 1]?.id ?? null) : null;
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
data,
|
|
185
|
+
error: null,
|
|
186
|
+
count: totalRows[0]?.value ?? 0,
|
|
187
|
+
cursor,
|
|
188
|
+
};
|
|
189
|
+
} catch (err) {
|
|
190
|
+
return { data: [], error: toError(err), count: null, cursor: null };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function* membersIterator(opts?: {
|
|
195
|
+
pageSize?: number;
|
|
196
|
+
}): AsyncIterableIterator<BucketMemberRow> {
|
|
197
|
+
const pageSize = Math.min(opts?.pageSize ?? DEFAULT_PAGE, MAX_PAGE);
|
|
198
|
+
let cursor: string | null | undefined;
|
|
199
|
+
// The only full-population traversal — bounded page-by-page via members().
|
|
200
|
+
while (true) {
|
|
201
|
+
const page = await members({
|
|
202
|
+
limit: pageSize,
|
|
203
|
+
cursor: cursor ?? undefined,
|
|
204
|
+
});
|
|
205
|
+
if (page.error) throw page.error;
|
|
206
|
+
for (const row of page.data) yield row;
|
|
207
|
+
if (!page.cursor) break;
|
|
208
|
+
cursor = page.cursor;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return { count, has, members, membersIterator };
|
|
213
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import type { DurationObject } from "@hogsend/core";
|
|
2
|
+
import { durationToMs } from "@hogsend/core";
|
|
3
|
+
import type {
|
|
4
|
+
JourneyContext,
|
|
5
|
+
JourneyMeta,
|
|
6
|
+
JourneyUser,
|
|
7
|
+
} from "@hogsend/core/types";
|
|
8
|
+
import {
|
|
9
|
+
type DefinedJourney,
|
|
10
|
+
defineJourney,
|
|
11
|
+
} from "../journeys/define-journey.js";
|
|
12
|
+
import type { DefinedBucket } from "./define-bucket.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Why a reaction reaches for THIS leave reason. Carried on the emitted
|
|
16
|
+
* `bucket:left:<id>` event properties and surfaced to a `leave` handler as
|
|
17
|
+
* `ctx.reason`. `"manual"` is reserved for a future force-leave path.
|
|
18
|
+
*/
|
|
19
|
+
export type BucketLeaveReason = "criteria" | "maxDwell" | "manual";
|
|
20
|
+
|
|
21
|
+
/** `bucket.on("enter", opts?, handler)` options. */
|
|
22
|
+
export interface EnterOptions {
|
|
23
|
+
/**
|
|
24
|
+
* Only run the handler on the user's FIRST entry to this bucket (re-entries
|
|
25
|
+
* are skipped). Re-entry is a FILTER, never a separate event — the filter runs
|
|
26
|
+
* inside `run` AFTER enrollment (a filtered-out entry still writes a short
|
|
27
|
+
* active→completed `journeyStates` row).
|
|
28
|
+
*/
|
|
29
|
+
firstEntryOnly?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `bucket.on("leave", opts?, handler)` options. */
|
|
33
|
+
export interface LeaveOptions {
|
|
34
|
+
/**
|
|
35
|
+
* Only run the handler when the leave matches this reason (or one of these
|
|
36
|
+
* reasons). Filter runs inside `run` AFTER enrollment.
|
|
37
|
+
*/
|
|
38
|
+
reason?: BucketLeaveReason | BucketLeaveReason[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* `bucket.on("dwell", opts, handler)` options — exactly one of `after`/`every`.
|
|
43
|
+
* - `after`: one-shot, fires once when the member has dwelt continuously for
|
|
44
|
+
* the duration.
|
|
45
|
+
* - `every`: recurring, fires (coalescing) once per elapsed interval.
|
|
46
|
+
*/
|
|
47
|
+
export type DwellOptions =
|
|
48
|
+
| { after: DurationObject; every?: never }
|
|
49
|
+
| { every: DurationObject; after?: never };
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Reaction-specific read-only extras layered onto the canonical
|
|
53
|
+
* `JourneyContext` for the handler, discriminated by the reaction kind.
|
|
54
|
+
*/
|
|
55
|
+
export type ReactionExtras<K> = K extends "enter"
|
|
56
|
+
? { entryCount: number; isFirstEntry: boolean }
|
|
57
|
+
: K extends "leave"
|
|
58
|
+
? { reason: BucketLeaveReason }
|
|
59
|
+
: { dwellCount: number };
|
|
60
|
+
|
|
61
|
+
/** The ctx a reaction handler receives: full JourneyContext + kind extras. */
|
|
62
|
+
export type BucketReactionCtx<K extends "enter" | "leave" | "dwell"> =
|
|
63
|
+
JourneyContext & ReactionExtras<K>;
|
|
64
|
+
|
|
65
|
+
/** A bucket-reaction handler — same `(user, ctx)` shape as a journey `run`. */
|
|
66
|
+
export type BucketOnHandler<K extends "enter" | "leave" | "dwell"> = (
|
|
67
|
+
user: JourneyUser,
|
|
68
|
+
ctx: BucketReactionCtx<K>,
|
|
69
|
+
) => Promise<void>;
|
|
70
|
+
|
|
71
|
+
/** Coerce a single value or array to an array. */
|
|
72
|
+
export function asArray<T>(value: T | T[]): T[] {
|
|
73
|
+
return Array.isArray(value) ? value : [value];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Stable, schedule-unique label for a dwell reaction: `after-<ms>` / `every-<ms>`.
|
|
78
|
+
* Hatchet keys workflows by `journey-${id}`, so two dwell reactions on one bucket
|
|
79
|
+
* (one `after`, one `every`) get distinct, boot-stable ids/events.
|
|
80
|
+
*/
|
|
81
|
+
export function dwellLabel(opts: DwellOptions): string {
|
|
82
|
+
return opts.after != null
|
|
83
|
+
? `after-${durationToMs(opts.after)}`
|
|
84
|
+
: `every-${durationToMs(opts.every)}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The schema persisted on the reaction meta (read by the dwell cron). */
|
|
88
|
+
export function parseDwellSchedule(opts: DwellOptions): {
|
|
89
|
+
label: string;
|
|
90
|
+
after?: number;
|
|
91
|
+
every?: number;
|
|
92
|
+
} {
|
|
93
|
+
return opts.after != null
|
|
94
|
+
? { label: dwellLabel(opts), after: durationToMs(opts.after) }
|
|
95
|
+
: { label: dwellLabel(opts), every: durationToMs(opts.every) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Derive the generated reaction journey id from the bucket id + kind. */
|
|
99
|
+
export function reactionJourneyId(
|
|
100
|
+
bucketId: string,
|
|
101
|
+
kind: "enter" | "leave" | "dwell",
|
|
102
|
+
opts: EnterOptions | LeaveOptions | DwellOptions | undefined,
|
|
103
|
+
): string {
|
|
104
|
+
return kind === "dwell"
|
|
105
|
+
? `bucket-${bucketId}-on-dwell-${dwellLabel(opts as DwellOptions)}`
|
|
106
|
+
: `bucket-${bucketId}-on-${kind}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Discriminate the `(opts?, handler)` overload by argument type: a function in
|
|
111
|
+
* the first slot is the handler (opts undefined); an object in the first slot is
|
|
112
|
+
* opts and the second slot is the handler. For `dwell`, opts is mandatory and
|
|
113
|
+
* must carry exactly one of `after`/`every` — a `TypeError` otherwise.
|
|
114
|
+
*/
|
|
115
|
+
export function normalizeOnArgs(
|
|
116
|
+
kind: "enter" | "leave" | "dwell",
|
|
117
|
+
a: unknown,
|
|
118
|
+
b?: unknown,
|
|
119
|
+
): {
|
|
120
|
+
opts: EnterOptions | LeaveOptions | DwellOptions | undefined;
|
|
121
|
+
handler: BucketOnHandler<"enter" | "leave" | "dwell">;
|
|
122
|
+
} {
|
|
123
|
+
let opts: EnterOptions | LeaveOptions | DwellOptions | undefined;
|
|
124
|
+
let handler: BucketOnHandler<"enter" | "leave" | "dwell">;
|
|
125
|
+
|
|
126
|
+
if (typeof a === "function") {
|
|
127
|
+
opts = undefined;
|
|
128
|
+
handler = a as BucketOnHandler<"enter" | "leave" | "dwell">;
|
|
129
|
+
} else {
|
|
130
|
+
opts = a as EnterOptions | LeaveOptions | DwellOptions | undefined;
|
|
131
|
+
handler = b as BucketOnHandler<"enter" | "leave" | "dwell">;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (typeof handler !== "function") {
|
|
135
|
+
throw new TypeError(`bucket.on("${kind}") requires a handler function`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (kind === "dwell") {
|
|
139
|
+
const dwell = opts as DwellOptions | undefined;
|
|
140
|
+
const hasAfter = dwell?.after != null;
|
|
141
|
+
const hasEvery = dwell?.every != null;
|
|
142
|
+
if (hasAfter === hasEvery) {
|
|
143
|
+
throw new TypeError(
|
|
144
|
+
'bucket.on("dwell") requires exactly one of `after` or `every`',
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { opts, handler };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Desugar `bucket.on(kind, opts?, handler)` to a real `defineJourney` output
|
|
154
|
+
* tagged with `sourceBucketId` + `reactionKind`. The reaction IS a journey, so
|
|
155
|
+
* it inherits the entire enrollment guard stack, the active-state dedup, the
|
|
156
|
+
* durable context, and event routing for free.
|
|
157
|
+
*
|
|
158
|
+
* The handler ctx is built by SPREAD (`{ ...ctx, ...extras }`), never by
|
|
159
|
+
* mutating the engine's canonical ctx (which is shared/closed). The
|
|
160
|
+
* `firstEntryOnly`/`reason` filters run inside `run` AFTER enrollment.
|
|
161
|
+
*/
|
|
162
|
+
export function buildBucketReaction(args: {
|
|
163
|
+
bucket: DefinedBucket;
|
|
164
|
+
kind: "enter" | "leave" | "dwell";
|
|
165
|
+
opts: EnterOptions | LeaveOptions | DwellOptions | undefined;
|
|
166
|
+
handler: BucketOnHandler<"enter" | "leave" | "dwell">;
|
|
167
|
+
}): DefinedJourney {
|
|
168
|
+
const { bucket, kind, opts, handler } = args;
|
|
169
|
+
|
|
170
|
+
const triggerEvent =
|
|
171
|
+
kind === "enter"
|
|
172
|
+
? bucket.entered
|
|
173
|
+
: kind === "leave"
|
|
174
|
+
? bucket.left
|
|
175
|
+
: `bucket:dwell:${bucket.meta.id}:${dwellLabel(opts as DwellOptions)}`;
|
|
176
|
+
|
|
177
|
+
const meta: JourneyMeta = {
|
|
178
|
+
id: reactionJourneyId(bucket.meta.id, kind, opts),
|
|
179
|
+
name: `${bucket.meta.name} — on ${kind}`,
|
|
180
|
+
enabled: bucket.meta.enabled,
|
|
181
|
+
trigger: { event: triggerEvent },
|
|
182
|
+
// Re-entry is a FILTER, never gated here.
|
|
183
|
+
entryLimit: "unlimited",
|
|
184
|
+
// Reactions intentionally have no re-entry cool-down.
|
|
185
|
+
suppress: { seconds: 0 },
|
|
186
|
+
sourceBucketId: bucket.meta.id,
|
|
187
|
+
reactionKind: kind,
|
|
188
|
+
...(kind === "dwell"
|
|
189
|
+
? { dwellSchedule: parseDwellSchedule(opts as DwellOptions) }
|
|
190
|
+
: {}),
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
return defineJourney({
|
|
194
|
+
meta,
|
|
195
|
+
run: async (user, ctx) => {
|
|
196
|
+
const p = user.properties;
|
|
197
|
+
if (kind === "enter") {
|
|
198
|
+
const entryCount = Number(p.entryCount ?? 1);
|
|
199
|
+
const isFirstEntry = entryCount === 1;
|
|
200
|
+
if (
|
|
201
|
+
(opts as EnterOptions | undefined)?.firstEntryOnly &&
|
|
202
|
+
!isFirstEntry
|
|
203
|
+
) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
await (handler as BucketOnHandler<"enter">)(user, {
|
|
207
|
+
...ctx,
|
|
208
|
+
entryCount,
|
|
209
|
+
isFirstEntry,
|
|
210
|
+
});
|
|
211
|
+
} else if (kind === "leave") {
|
|
212
|
+
const reason = (p.reason as BucketLeaveReason) ?? "criteria";
|
|
213
|
+
const want = (opts as LeaveOptions | undefined)?.reason;
|
|
214
|
+
if (want && !asArray(want).includes(reason)) return;
|
|
215
|
+
await (handler as BucketOnHandler<"leave">)(user, { ...ctx, reason });
|
|
216
|
+
} else {
|
|
217
|
+
const dwellCount = Number(p.dwellCount ?? 1);
|
|
218
|
+
await (handler as BucketOnHandler<"dwell">)(user, {
|
|
219
|
+
...ctx,
|
|
220
|
+
dwellCount,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
}
|
|
@@ -51,7 +51,16 @@ export async function checkBucketMembership(opts: {
|
|
|
51
51
|
userId: string;
|
|
52
52
|
userEmail: string | null;
|
|
53
53
|
event: string;
|
|
54
|
-
|
|
54
|
+
/**
|
|
55
|
+
* D2: the event payload — candidate-narrowing ONLY. It NO LONGER participates
|
|
56
|
+
* in property eval (the raw-payload overlay was the bucket-side conflation).
|
|
57
|
+
*/
|
|
58
|
+
eventProperties: Record<string, unknown>;
|
|
59
|
+
/**
|
|
60
|
+
* D2: this-ingest contact-property patch, overlaid on the read contact row so
|
|
61
|
+
* the very first event after a property change evaluates correctly (risk 7).
|
|
62
|
+
*/
|
|
63
|
+
contactProperties?: Record<string, unknown>;
|
|
55
64
|
/** Optional override; defaults to the process bucket-registry singleton. */
|
|
56
65
|
bucketRegistry?: ReturnType<typeof getBucketRegistrySingleton>;
|
|
57
66
|
}): Promise<BucketTransition[]> {
|
|
@@ -63,7 +72,8 @@ export async function checkBucketMembership(opts: {
|
|
|
63
72
|
userId,
|
|
64
73
|
userEmail,
|
|
65
74
|
event,
|
|
66
|
-
|
|
75
|
+
eventProperties,
|
|
76
|
+
contactProperties: contactPropertiesPatch,
|
|
67
77
|
} = opts;
|
|
68
78
|
|
|
69
79
|
// (1) Recursion guard — MUST be first. bucket:-prefixed events are transition
|
|
@@ -81,12 +91,18 @@ export async function checkBucketMembership(opts: {
|
|
|
81
91
|
|
|
82
92
|
// (2) Candidate narrowing — the UNION of buckets referencing this event name
|
|
83
93
|
// (eventIndex + the degenerate wildcard set) and buckets referencing any
|
|
84
|
-
// property present in
|
|
94
|
+
// property present in EITHER bag (propertyIndex): the eventProperties drive
|
|
95
|
+
// event-shaped criteria narrowing, the contactProperties patch surfaces a
|
|
96
|
+
// contact-property change so a property-criteria bucket is re-checked on the
|
|
97
|
+
// first event that mutates it. Section 6.2.
|
|
85
98
|
const candidateMap = new Map<string, BucketMeta>();
|
|
86
99
|
for (const bucket of bucketRegistry.getByReferencedEvent(event)) {
|
|
87
100
|
candidateMap.set(bucket.id, bucket);
|
|
88
101
|
}
|
|
89
|
-
for (const key of
|
|
102
|
+
for (const key of [
|
|
103
|
+
...Object.keys(eventProperties ?? {}),
|
|
104
|
+
...Object.keys(contactPropertiesPatch ?? {}),
|
|
105
|
+
]) {
|
|
90
106
|
for (const bucket of bucketRegistry.getByReferencedProperty(key)) {
|
|
91
107
|
candidateMap.set(bucket.id, bucket);
|
|
92
108
|
}
|
|
@@ -109,19 +125,20 @@ export async function checkBucketMembership(opts: {
|
|
|
109
125
|
return [];
|
|
110
126
|
}
|
|
111
127
|
|
|
112
|
-
// (3) Property predicates evaluate against
|
|
113
|
-
// event payload (Section 6.1 rule #3
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
128
|
+
// (3) Property predicates evaluate against contact state ⊕ this-ingest
|
|
129
|
+
// contactProperties patch — NOT the raw event payload (Section 6.1 rule #3 /
|
|
130
|
+
// D2). Read the EXISTING contacts row ONCE iff any surviving candidate
|
|
131
|
+
// references a property — pure event/count buckets skip the read entirely.
|
|
132
|
+
// `ingestEvent` already awaited `resolveOrCreateContact` before us, so the row
|
|
133
|
+
// exists by the resolved key; the patch overlay still covers the read-after-
|
|
134
|
+
// write gap on a contact's very first event (risk 7).
|
|
118
135
|
const needsContactState = candidates.some(
|
|
119
136
|
(bucket) =>
|
|
120
137
|
bucket.criteria != null &&
|
|
121
138
|
collectPropertyNames(bucket.criteria).length > 0,
|
|
122
139
|
);
|
|
123
140
|
|
|
124
|
-
let
|
|
141
|
+
let storedContactProps: Record<string, unknown> = {};
|
|
125
142
|
let contactDeleted = false;
|
|
126
143
|
if (needsContactState) {
|
|
127
144
|
const [contact] = await db
|
|
@@ -133,7 +150,7 @@ export async function checkBucketMembership(opts: {
|
|
|
133
150
|
.where(eq(contacts.externalId, userId))
|
|
134
151
|
.limit(1);
|
|
135
152
|
if (contact) {
|
|
136
|
-
|
|
153
|
+
storedContactProps =
|
|
137
154
|
(contact.properties as Record<string, unknown> | null) ?? {};
|
|
138
155
|
contactDeleted = contact.deletedAt != null;
|
|
139
156
|
}
|
|
@@ -144,10 +161,12 @@ export async function checkBucketMembership(opts: {
|
|
|
144
161
|
return [];
|
|
145
162
|
}
|
|
146
163
|
|
|
147
|
-
//
|
|
164
|
+
// this-ingest contactProperties patch overlays stored contact state. The raw
|
|
165
|
+
// event payload is REMOVED from property eval (D2 — bucket prop-criteria see
|
|
166
|
+
// contact state only).
|
|
148
167
|
const journeyContext: Record<string, unknown> = {
|
|
149
|
-
...
|
|
150
|
-
...(
|
|
168
|
+
...storedContactProps,
|
|
169
|
+
...(contactPropertiesPatch ?? {}),
|
|
151
170
|
};
|
|
152
171
|
|
|
153
172
|
const transitions: BucketTransition[] = [];
|
|
@@ -379,6 +398,7 @@ async function handleLeave(opts: {
|
|
|
379
398
|
userEmail,
|
|
380
399
|
epoch: flipped.entryCount,
|
|
381
400
|
source: "event",
|
|
401
|
+
reason: "criteria",
|
|
382
402
|
});
|
|
383
403
|
|
|
384
404
|
return { bucketId: bucket.id, transition: "left" };
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { type CriteriaBuilder, criteriaBuilder } from "@hogsend/core";
|
|
2
2
|
import type { BucketMeta, ConditionEval } from "@hogsend/core/types";
|
|
3
|
+
import type { DefinedJourney } from "../journeys/define-journey.js";
|
|
3
4
|
import type { hatchet } from "../lib/hatchet.js";
|
|
5
|
+
import { type BucketAccessor, createBucketAccessor } from "./bucket-access.js";
|
|
6
|
+
import {
|
|
7
|
+
type BucketOnHandler,
|
|
8
|
+
buildBucketReaction,
|
|
9
|
+
type DwellOptions,
|
|
10
|
+
type EnterOptions,
|
|
11
|
+
type LeaveOptions,
|
|
12
|
+
normalizeOnArgs,
|
|
13
|
+
} from "./bucket-reactions.js";
|
|
4
14
|
|
|
5
15
|
/**
|
|
6
16
|
* `criteria` may be authored two ways:
|
|
@@ -15,12 +25,19 @@ export type CriteriaInput =
|
|
|
15
25
|
| ((b: CriteriaBuilder) => ConditionEval);
|
|
16
26
|
|
|
17
27
|
/** `BucketMeta` as authored — `criteria` accepts the builder function too. */
|
|
18
|
-
export type BucketMetaInput =
|
|
19
|
-
|
|
20
|
-
|
|
28
|
+
export type BucketMetaInput<Id extends string = string> = Omit<
|
|
29
|
+
BucketMeta,
|
|
30
|
+
"criteria" | "id"
|
|
31
|
+
> & { id: Id; criteria?: CriteriaInput };
|
|
21
32
|
|
|
22
|
-
export
|
|
33
|
+
export type { DwellOptions };
|
|
34
|
+
|
|
35
|
+
export interface DefinedBucket<Id extends string = string> {
|
|
23
36
|
meta: BucketMeta;
|
|
37
|
+
/** Literal-typed transition ref: `"bucket:entered:<id>"`. */
|
|
38
|
+
readonly entered: `bucket:entered:${Id}`;
|
|
39
|
+
/** Literal-typed transition ref: `"bucket:left:<id>"`. */
|
|
40
|
+
readonly left: `bucket:left:${Id}`;
|
|
24
41
|
/**
|
|
25
42
|
* The only task a bucket ever holds is the opt-in per-user fast-expiry timer,
|
|
26
43
|
* which is a DURABLE task (it `ctx.sleepFor`s — Section 6.5), so the type MUST
|
|
@@ -31,11 +48,34 @@ export interface DefinedBucket {
|
|
|
31
48
|
* `bucketReconcileTask` handles time-based leaves regardless.
|
|
32
49
|
*/
|
|
33
50
|
task?: ReturnType<typeof hatchet.durableTask>;
|
|
51
|
+
/** Reaction journeys generated by `.on()`. Read by the worker + container. */
|
|
52
|
+
reactions: DefinedJourney[];
|
|
53
|
+
count: BucketAccessor["count"];
|
|
54
|
+
has: BucketAccessor["has"];
|
|
55
|
+
members: BucketAccessor["members"];
|
|
56
|
+
membersIterator: BucketAccessor["membersIterator"];
|
|
57
|
+
on(kind: "enter", handler: BucketOnHandler<"enter">): DefinedBucket<Id>;
|
|
58
|
+
on(
|
|
59
|
+
kind: "enter",
|
|
60
|
+
opts: EnterOptions,
|
|
61
|
+
handler: BucketOnHandler<"enter">,
|
|
62
|
+
): DefinedBucket<Id>;
|
|
63
|
+
on(kind: "leave", handler: BucketOnHandler<"leave">): DefinedBucket<Id>;
|
|
64
|
+
on(
|
|
65
|
+
kind: "leave",
|
|
66
|
+
opts: LeaveOptions,
|
|
67
|
+
handler: BucketOnHandler<"leave">,
|
|
68
|
+
): DefinedBucket<Id>;
|
|
69
|
+
on(
|
|
70
|
+
kind: "dwell",
|
|
71
|
+
opts: DwellOptions,
|
|
72
|
+
handler: BucketOnHandler<"dwell">,
|
|
73
|
+
): DefinedBucket<Id>;
|
|
34
74
|
}
|
|
35
75
|
|
|
36
|
-
export function defineBucket(options: {
|
|
37
|
-
meta: BucketMetaInput
|
|
38
|
-
}): DefinedBucket {
|
|
76
|
+
export function defineBucket<const Id extends string>(options: {
|
|
77
|
+
meta: BucketMetaInput<Id>;
|
|
78
|
+
}): DefinedBucket<Id> {
|
|
39
79
|
// The ONLY transform defineBucket performs is resolving a builder-function
|
|
40
80
|
// `criteria` to its `ConditionEval` (a one-shot, definition-time call). It does
|
|
41
81
|
// NOT validate or build any task — `bucketMetaSchema.parse` still happens at
|
|
@@ -48,5 +88,36 @@ export function defineBucket(options: {
|
|
|
48
88
|
criteria:
|
|
49
89
|
typeof criteria === "function" ? criteria(criteriaBuilder) : criteria,
|
|
50
90
|
};
|
|
51
|
-
|
|
91
|
+
|
|
92
|
+
// Pure string derivation — synchronous, no cross-module value binding (the
|
|
93
|
+
// ESM-cycle resolution): entered/left are stable the instant defineBucket
|
|
94
|
+
// returns, computed from meta.id, NOT bound to any other module's value.
|
|
95
|
+
const entered = `bucket:entered:${meta.id}` as `bucket:entered:${Id}`;
|
|
96
|
+
const left = `bucket:left:${meta.id}` as `bucket:left:${Id}`;
|
|
97
|
+
const reactions: DefinedJourney[] = [];
|
|
98
|
+
const accessor = createBucketAccessor(meta.id);
|
|
99
|
+
|
|
100
|
+
const bucket: DefinedBucket<Id> = {
|
|
101
|
+
meta,
|
|
102
|
+
entered,
|
|
103
|
+
left,
|
|
104
|
+
reactions,
|
|
105
|
+
count: accessor.count,
|
|
106
|
+
has: accessor.has,
|
|
107
|
+
members: accessor.members,
|
|
108
|
+
membersIterator: accessor.membersIterator,
|
|
109
|
+
on(kind: "enter" | "leave" | "dwell", a: unknown, b?: unknown) {
|
|
110
|
+
const { opts, handler } = normalizeOnArgs(kind, a, b);
|
|
111
|
+
reactions.push(
|
|
112
|
+
buildBucketReaction({
|
|
113
|
+
bucket: bucket as DefinedBucket,
|
|
114
|
+
kind,
|
|
115
|
+
opts,
|
|
116
|
+
handler,
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
return bucket;
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
return bucket;
|
|
52
123
|
}
|