@hogsend/engine 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 +6 -6
- package/src/app.ts +37 -0
- package/src/buckets/check-membership.ts +499 -0
- package/src/buckets/define-bucket.ts +29 -0
- package/src/buckets/registry-singleton.ts +21 -0
- package/src/buckets/registry.ts +62 -0
- package/src/container.ts +50 -2
- package/src/env.ts +10 -0
- package/src/index.ts +40 -1
- package/src/lib/auth.ts +8 -1
- package/src/lib/bucket-emit.ts +107 -0
- package/src/lib/bucket-posthog-sync.ts +63 -0
- package/src/lib/email-service-types.ts +6 -0
- package/src/lib/email.ts +2 -0
- package/src/lib/ingestion.ts +25 -0
- package/src/lib/mailer.ts +9 -1
- package/src/lib/metrics-sql.ts +17 -0
- package/src/lib/studio.ts +105 -0
- package/src/lib/tracked.ts +4 -0
- package/src/middleware/rate-limit.ts +1 -1
- package/src/middleware/require-admin.ts +26 -0
- package/src/routes/admin/buckets.ts +464 -0
- package/src/routes/admin/emails.ts +155 -9
- package/src/routes/admin/index.ts +10 -2
- package/src/routes/admin/metrics.ts +286 -23
- package/src/routes/admin/reporting.ts +369 -0
- package/src/routes/admin/suppressions.ts +99 -0
- package/src/routes/admin/templates.ts +298 -0
- package/src/worker.ts +35 -0
- package/src/workflows/bucket-backfill.ts +556 -0
- package/src/workflows/bucket-reconcile.ts +721 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -35,11 +35,11 @@
|
|
|
35
35
|
"resend": "^6.12.3",
|
|
36
36
|
"winston": "^3.19.0",
|
|
37
37
|
"zod": "^4.4.3",
|
|
38
|
-
"@hogsend/core": "^0.
|
|
39
|
-
"@hogsend/db": "^0.
|
|
40
|
-
"@hogsend/email": "^0.0
|
|
41
|
-
"@hogsend/plugin-posthog": "^0.0
|
|
42
|
-
"@hogsend/plugin-resend": "^0.0
|
|
38
|
+
"@hogsend/core": "^0.2.0",
|
|
39
|
+
"@hogsend/db": "^0.2.0",
|
|
40
|
+
"@hogsend/email": "^0.1.0",
|
|
41
|
+
"@hogsend/plugin-posthog": "^0.1.0",
|
|
42
|
+
"@hogsend/plugin-resend": "^0.1.0"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@types/node": "^22.15.3",
|
package/src/app.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { user } from "@hogsend/db";
|
|
1
2
|
import { OpenAPIHono } from "@hono/zod-openapi";
|
|
2
3
|
import { apiReference } from "@scalar/hono-api-reference";
|
|
3
4
|
import { compress } from "hono/compress";
|
|
@@ -8,6 +9,7 @@ import type { ErrorHandler, MiddlewareHandler } from "hono/types";
|
|
|
8
9
|
import type { HogsendClient } from "./container.js";
|
|
9
10
|
import { API_VERSION } from "./env.js";
|
|
10
11
|
import type { Auth } from "./lib/auth.js";
|
|
12
|
+
import { mountStudio } from "./lib/studio.js";
|
|
11
13
|
import type { ApiKeyContext } from "./middleware/api-key.js";
|
|
12
14
|
import { errorHandler } from "./middleware/error-handler.js";
|
|
13
15
|
import { requestLogger } from "./middleware/request-logger.js";
|
|
@@ -64,13 +66,48 @@ export function createApp(
|
|
|
64
66
|
return c.json({ error: "Not Found" }, 404);
|
|
65
67
|
});
|
|
66
68
|
|
|
69
|
+
// Closed signup: the first user may register (first-load "create admin");
|
|
70
|
+
// once any user exists, sign-up is blocked. This is the security control that
|
|
71
|
+
// lets `requireAdmin` trust any authenticated session in a single-tenant app.
|
|
72
|
+
app.use("/api/auth/sign-up/*", async (c, next) => {
|
|
73
|
+
if (c.req.method === "POST") {
|
|
74
|
+
const { db } = c.get("container");
|
|
75
|
+
const existing = await db.select({ id: user.id }).from(user).limit(1);
|
|
76
|
+
if (existing.length > 0) {
|
|
77
|
+
return c.json(
|
|
78
|
+
{ error: "Sign-ups are closed. An admin already exists." },
|
|
79
|
+
403,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return next();
|
|
84
|
+
});
|
|
85
|
+
|
|
67
86
|
app.on(["POST", "GET"], "/api/auth/*", (c) => {
|
|
68
87
|
const { auth } = c.get("container");
|
|
69
88
|
return auth.handler(c.req.raw);
|
|
70
89
|
});
|
|
71
90
|
|
|
91
|
+
// Public bootstrap probe: tells the Studio whether to show the first-run
|
|
92
|
+
// "create admin" screen (no users yet) instead of the login screen.
|
|
93
|
+
app.get("/v1/auth/status", async (c) => {
|
|
94
|
+
const { db } = c.get("container");
|
|
95
|
+
const existing = await db.select({ id: user.id }).from(user).limit(1);
|
|
96
|
+
return c.json({ needsSetup: existing.length === 0 });
|
|
97
|
+
});
|
|
98
|
+
|
|
72
99
|
registerRoutes(app, { webhookSources: opts.webhookSources ?? [] });
|
|
73
100
|
|
|
101
|
+
// Serve the Studio SPA at /studio/* (static layer, no auth — the SPA gates
|
|
102
|
+
// itself via /v1/auth/status + login; data endpoints stay behind requireAdmin).
|
|
103
|
+
// No-op when no built dist is present, so an unbuilt studio never crashes boot.
|
|
104
|
+
const studio = mountStudio(app);
|
|
105
|
+
if (studio.mounted) {
|
|
106
|
+
container.logger.info(
|
|
107
|
+
`Studio mounted at /studio (dist: ${studio.distPath})`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
74
111
|
opts.routes?.(app);
|
|
75
112
|
|
|
76
113
|
if (container.env.NODE_ENV !== "production") {
|
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
|
|
2
|
+
import {
|
|
3
|
+
type BucketMeta,
|
|
4
|
+
collectPropertyNames,
|
|
5
|
+
durationToMs,
|
|
6
|
+
evaluateCondition,
|
|
7
|
+
} from "@hogsend/core";
|
|
8
|
+
import type { JourneyRegistry } from "@hogsend/core/registry";
|
|
9
|
+
import { bucketMemberships, contacts, type Database } from "@hogsend/db";
|
|
10
|
+
import { and, eq, isNull, sql } from "drizzle-orm";
|
|
11
|
+
import { emitBucketTransition } from "../lib/bucket-emit.js";
|
|
12
|
+
import type { Logger } from "../lib/logger.js";
|
|
13
|
+
import { getBucketRegistrySingleton } from "./registry-singleton.js";
|
|
14
|
+
|
|
15
|
+
/** The reserved prefix every bucket transition event carries. */
|
|
16
|
+
const BUCKET_EVENT_PREFIX = "bucket:";
|
|
17
|
+
|
|
18
|
+
export type BucketTransitionKind = "entered" | "left";
|
|
19
|
+
|
|
20
|
+
export interface BucketTransition {
|
|
21
|
+
bucketId: string;
|
|
22
|
+
transition: BucketTransitionKind;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Real-time bucket-membership re-evaluation, invoked from inside `ingestEvent`
|
|
27
|
+
* AFTER the `userEvents` insert / idempotency short-circuit (Section 6.1).
|
|
28
|
+
*
|
|
29
|
+
* For the ingested event it narrows the candidate buckets via the registry's
|
|
30
|
+
* event + property inverted indexes (Section 6.2), evaluates each candidate's
|
|
31
|
+
* criteria against MERGED contact state (Section 6.1 rule #3), diffs the result
|
|
32
|
+
* against the current `bucket_memberships` rows, and performs the atomic
|
|
33
|
+
* RETURNING-gated mutation (partial-unique INSERT for joins, compare-and-swap
|
|
34
|
+
* UPDATE for leaves — Section 6.3). On a real transition it emits
|
|
35
|
+
* `bucket:entered:<id>` / `bucket:left:<id>` back through `ingestEvent`, gated on
|
|
36
|
+
* the reentry policy and deferring leaves still inside `minDwell`.
|
|
37
|
+
*
|
|
38
|
+
* Returns the computed transition list so a unit test can assert enter/leave/no-op
|
|
39
|
+
* WITHOUT a live Hatchet (Section 14 — the testing seam). Production callers
|
|
40
|
+
* ignore the return value (the emission has already happened via recursion).
|
|
41
|
+
*/
|
|
42
|
+
export async function checkBucketMembership(opts: {
|
|
43
|
+
db: Database;
|
|
44
|
+
/** The JOURNEY registry — forwarded into the recursive emit ingestEvent. */
|
|
45
|
+
registry: JourneyRegistry;
|
|
46
|
+
hatchet: HatchetClient;
|
|
47
|
+
logger: Logger;
|
|
48
|
+
userId: string;
|
|
49
|
+
userEmail: string | null;
|
|
50
|
+
event: string;
|
|
51
|
+
properties: Record<string, unknown>;
|
|
52
|
+
/** Optional override; defaults to the process bucket-registry singleton. */
|
|
53
|
+
bucketRegistry?: ReturnType<typeof getBucketRegistrySingleton>;
|
|
54
|
+
}): Promise<BucketTransition[]> {
|
|
55
|
+
const {
|
|
56
|
+
db,
|
|
57
|
+
registry,
|
|
58
|
+
hatchet,
|
|
59
|
+
logger,
|
|
60
|
+
userId,
|
|
61
|
+
userEmail,
|
|
62
|
+
event,
|
|
63
|
+
properties,
|
|
64
|
+
} = opts;
|
|
65
|
+
|
|
66
|
+
// (1) Recursion guard — MUST be first. bucket:-prefixed events are transition
|
|
67
|
+
// rows (still written to userEvents / pushed to Hatchet / run through
|
|
68
|
+
// checkExits) but MUST NOT trigger bucket re-evaluation, else the emit recurses
|
|
69
|
+
// forever. ingestEvent has no built-in re-entry guard, so this prefix check is
|
|
70
|
+
// the bound on recursion (Section 6.1 rule #1).
|
|
71
|
+
if (event.startsWith(BUCKET_EVENT_PREFIX)) {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The bucket registry is resolved separately from the journey registry; the
|
|
76
|
+
// two are never conflated (Section 6.1 signature note).
|
|
77
|
+
const bucketRegistry = opts.bucketRegistry ?? getBucketRegistrySingleton();
|
|
78
|
+
|
|
79
|
+
// (2) Candidate narrowing — the UNION of buckets referencing this event name
|
|
80
|
+
// (eventIndex + the degenerate wildcard set) and buckets referencing any
|
|
81
|
+
// property present in this payload (propertyIndex). Section 6.2.
|
|
82
|
+
const candidateMap = new Map<string, BucketMeta>();
|
|
83
|
+
for (const bucket of bucketRegistry.getByReferencedEvent(event)) {
|
|
84
|
+
candidateMap.set(bucket.id, bucket);
|
|
85
|
+
}
|
|
86
|
+
for (const key of Object.keys(properties ?? {})) {
|
|
87
|
+
for (const bucket of bucketRegistry.getByReferencedProperty(key)) {
|
|
88
|
+
candidateMap.set(bucket.id, bucket);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (candidateMap.size === 0) {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const candidates = Array.from(candidateMap.values()).filter(
|
|
97
|
+
// manual buckets are not criteria-driven; they never appear in the indexes,
|
|
98
|
+
// but guard defensively. enabled is the static load-time flag (the DB
|
|
99
|
+
// bucket_configs override is a later-phase concern, not read on this hot
|
|
100
|
+
// path — Section 6.2).
|
|
101
|
+
(bucket) =>
|
|
102
|
+
bucket.enabled && bucket.kind !== "manual" && bucket.criteria != null,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
if (candidates.length === 0) {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// (3) Property predicates evaluate against MERGED contact state, NOT the bare
|
|
110
|
+
// event payload (Section 6.1 rule #3). Read the EXISTING contacts row ONCE iff
|
|
111
|
+
// any surviving candidate references a property — pure event/count buckets skip
|
|
112
|
+
// the read entirely. We read the row that already exists (not the one
|
|
113
|
+
// upsertContact is concurrently writing) so we do not depend on the
|
|
114
|
+
// fire-and-forget upsert having run.
|
|
115
|
+
const needsContactState = candidates.some(
|
|
116
|
+
(bucket) =>
|
|
117
|
+
bucket.criteria != null &&
|
|
118
|
+
collectPropertyNames(bucket.criteria).length > 0,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
let contactProperties: Record<string, unknown> = {};
|
|
122
|
+
let contactDeleted = false;
|
|
123
|
+
if (needsContactState) {
|
|
124
|
+
const [contact] = await db
|
|
125
|
+
.select({
|
|
126
|
+
properties: contacts.properties,
|
|
127
|
+
deletedAt: contacts.deletedAt,
|
|
128
|
+
})
|
|
129
|
+
.from(contacts)
|
|
130
|
+
.where(eq(contacts.externalId, userId))
|
|
131
|
+
.limit(1);
|
|
132
|
+
if (contact) {
|
|
133
|
+
contactProperties =
|
|
134
|
+
(contact.properties as Record<string, unknown> | null) ?? {};
|
|
135
|
+
contactDeleted = contact.deletedAt != null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// GDPR: never (re-)evaluate or emit for a soft-deleted contact (Section 8.6).
|
|
140
|
+
if (contactDeleted) {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// event payload overlays cumulative contact state.
|
|
145
|
+
const journeyContext: Record<string, unknown> = {
|
|
146
|
+
...contactProperties,
|
|
147
|
+
...(properties ?? {}),
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const transitions: BucketTransition[] = [];
|
|
151
|
+
|
|
152
|
+
for (const bucket of candidates) {
|
|
153
|
+
if (!bucket.criteria) continue;
|
|
154
|
+
|
|
155
|
+
// wasMember — current active, non-deleted membership row (cheap pre-filter;
|
|
156
|
+
// the authoritative guard is the RETURNING-gated mutation below).
|
|
157
|
+
const active = await db.query.bucketMemberships.findFirst({
|
|
158
|
+
where: and(
|
|
159
|
+
eq(bucketMemberships.userId, userId),
|
|
160
|
+
eq(bucketMemberships.bucketId, bucket.id),
|
|
161
|
+
eq(bucketMemberships.status, "active"),
|
|
162
|
+
isNull(bucketMemberships.deletedAt),
|
|
163
|
+
),
|
|
164
|
+
});
|
|
165
|
+
const wasMember = !!active;
|
|
166
|
+
|
|
167
|
+
// isMember — the criteria evaluation. event/count sub-conditions read
|
|
168
|
+
// userEvents (the just-stored row is visible on the same connection — the
|
|
169
|
+
// documented no-pooler assumption, Section 6.1 rule #2); property
|
|
170
|
+
// sub-conditions read the merged journeyContext.
|
|
171
|
+
const isMember = await evaluateCondition({
|
|
172
|
+
condition: bucket.criteria,
|
|
173
|
+
ctx: { db, userId, journeyContext },
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
if (!wasMember && isMember) {
|
|
177
|
+
const transition = await handleJoin({
|
|
178
|
+
db,
|
|
179
|
+
registry,
|
|
180
|
+
hatchet,
|
|
181
|
+
logger,
|
|
182
|
+
bucket,
|
|
183
|
+
userId,
|
|
184
|
+
userEmail,
|
|
185
|
+
});
|
|
186
|
+
if (transition) transitions.push(transition);
|
|
187
|
+
} else if (wasMember && isMember) {
|
|
188
|
+
// stable member → no transition, no emit. Cheap observability bump.
|
|
189
|
+
await db
|
|
190
|
+
.update(bucketMemberships)
|
|
191
|
+
.set({ lastEvaluatedAt: new Date() })
|
|
192
|
+
.where(eq(bucketMemberships.id, active.id));
|
|
193
|
+
} else if (wasMember && !isMember) {
|
|
194
|
+
const transition = await handleLeave({
|
|
195
|
+
db,
|
|
196
|
+
registry,
|
|
197
|
+
hatchet,
|
|
198
|
+
logger,
|
|
199
|
+
bucket,
|
|
200
|
+
active,
|
|
201
|
+
userId,
|
|
202
|
+
userEmail,
|
|
203
|
+
});
|
|
204
|
+
if (transition) transitions.push(transition);
|
|
205
|
+
}
|
|
206
|
+
// !wasMember && !isMember → nothing.
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return transitions;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function handleJoin(opts: {
|
|
213
|
+
db: Database;
|
|
214
|
+
registry: JourneyRegistry;
|
|
215
|
+
hatchet: HatchetClient;
|
|
216
|
+
logger: Logger;
|
|
217
|
+
bucket: BucketMeta;
|
|
218
|
+
userId: string;
|
|
219
|
+
userEmail: string | null;
|
|
220
|
+
}): Promise<BucketTransition | null> {
|
|
221
|
+
const { db, registry, hatchet, logger, bucket, userId, userEmail } = opts;
|
|
222
|
+
|
|
223
|
+
// entryCount ordinal = 1 + count of ALL prior memberships (active + left) for
|
|
224
|
+
// this (user, bucket) (Section 6.3 / 8.2). priorCount also drives the reentry
|
|
225
|
+
// gate.
|
|
226
|
+
const [counted] = await db
|
|
227
|
+
.select({ priorCount: sql<number>`count(*)::int` })
|
|
228
|
+
.from(bucketMemberships)
|
|
229
|
+
.where(
|
|
230
|
+
and(
|
|
231
|
+
eq(bucketMemberships.userId, userId),
|
|
232
|
+
eq(bucketMemberships.bucketId, bucket.id),
|
|
233
|
+
),
|
|
234
|
+
);
|
|
235
|
+
const priorCount = Number(counted?.priorCount ?? 0);
|
|
236
|
+
const epoch = priorCount + 1;
|
|
237
|
+
|
|
238
|
+
// INSERT a FRESH active row. ON CONFLICT DO NOTHING targets the partial active
|
|
239
|
+
// unique index (uq_user_bucket_active): a concurrent emitter that already
|
|
240
|
+
// inserted the active row makes THIS insert return zero rows → we do NOT emit
|
|
241
|
+
// (the loser mutates nothing — Section 6.3 governing rule).
|
|
242
|
+
const expiresAt = computeExpiresAt(bucket);
|
|
243
|
+
// Unconditional TTL deadline — set once on join, swept by the reconcile cron.
|
|
244
|
+
const maxDwellAt = bucket.maxDwell
|
|
245
|
+
? new Date(Date.now() + durationToMs(bucket.maxDwell))
|
|
246
|
+
: null;
|
|
247
|
+
const inserted = await db
|
|
248
|
+
.insert(bucketMemberships)
|
|
249
|
+
.values({
|
|
250
|
+
userId,
|
|
251
|
+
userEmail,
|
|
252
|
+
bucketId: bucket.id,
|
|
253
|
+
status: "active",
|
|
254
|
+
source: "event",
|
|
255
|
+
entryCount: epoch,
|
|
256
|
+
expiresAt,
|
|
257
|
+
maxDwellAt,
|
|
258
|
+
lastEvaluatedAt: new Date(),
|
|
259
|
+
})
|
|
260
|
+
.onConflictDoNothing()
|
|
261
|
+
.returning({ id: bucketMemberships.id });
|
|
262
|
+
|
|
263
|
+
const insertedRow = inserted[0];
|
|
264
|
+
if (!insertedRow) {
|
|
265
|
+
// Lost the race; the winner emits. We did not change a row → no emit.
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Arm the per-user fast-expiry durable timer (Section 6.5) AFTER the active row
|
|
270
|
+
// is written. The cron remains the authoritative backstop, so a push failure
|
|
271
|
+
// is best-effort. We arm against the persisted expiresAt so the timer's CAS on
|
|
272
|
+
// wake matches the row (or no-ops if a later event re-armed the window).
|
|
273
|
+
if (bucket.fastExpiry && expiresAt) {
|
|
274
|
+
await armExpiryTimer({
|
|
275
|
+
hatchet,
|
|
276
|
+
logger,
|
|
277
|
+
bucket,
|
|
278
|
+
rowId: insertedRow.id,
|
|
279
|
+
userId,
|
|
280
|
+
userEmail,
|
|
281
|
+
expiresAt,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// The active row is always written (Studio size must reflect reality) and the
|
|
286
|
+
// epoch always advances via the real insert; only the bucket:entered emission
|
|
287
|
+
// is gated by the reentry policy (Section 6.3).
|
|
288
|
+
if (shouldEmitJoin(bucket, priorCount)) {
|
|
289
|
+
await emitBucketTransition({
|
|
290
|
+
db,
|
|
291
|
+
registry,
|
|
292
|
+
hatchet,
|
|
293
|
+
logger,
|
|
294
|
+
kind: "entered",
|
|
295
|
+
bucket,
|
|
296
|
+
userId,
|
|
297
|
+
userEmail,
|
|
298
|
+
epoch,
|
|
299
|
+
source: "event",
|
|
300
|
+
});
|
|
301
|
+
} else {
|
|
302
|
+
logger.info("Bucket join emit suppressed by reentry policy", {
|
|
303
|
+
bucketId: bucket.id,
|
|
304
|
+
userId,
|
|
305
|
+
reentry: bucket.reentry ?? "unlimited",
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return { bucketId: bucket.id, transition: "entered" };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function handleLeave(opts: {
|
|
313
|
+
db: Database;
|
|
314
|
+
registry: JourneyRegistry;
|
|
315
|
+
hatchet: HatchetClient;
|
|
316
|
+
logger: Logger;
|
|
317
|
+
bucket: BucketMeta;
|
|
318
|
+
active: typeof bucketMemberships.$inferSelect;
|
|
319
|
+
userId: string;
|
|
320
|
+
userEmail: string | null;
|
|
321
|
+
}): Promise<BucketTransition | null> {
|
|
322
|
+
const { db, registry, hatchet, logger, bucket, active, userId, userEmail } =
|
|
323
|
+
opts;
|
|
324
|
+
|
|
325
|
+
// minDwell DEFERS (never silently drops) the leave (Section 6.3). We set a
|
|
326
|
+
// pending-leave deadline on expiresAt = enteredAt + minDwell so the reconcile
|
|
327
|
+
// cron / fastExpiry timer re-checks after the dwell window and emits the leave
|
|
328
|
+
// via the CAS path. We do NOT emit now.
|
|
329
|
+
if (withinMinDwell(active, bucket)) {
|
|
330
|
+
const deadline = new Date(
|
|
331
|
+
active.enteredAt.getTime() +
|
|
332
|
+
durationToMs(bucket.minDwell as NonNullable<BucketMeta["minDwell"]>),
|
|
333
|
+
);
|
|
334
|
+
await db
|
|
335
|
+
.update(bucketMemberships)
|
|
336
|
+
.set({ expiresAt: deadline, lastEvaluatedAt: new Date() })
|
|
337
|
+
.where(
|
|
338
|
+
and(
|
|
339
|
+
eq(bucketMemberships.id, active.id),
|
|
340
|
+
eq(bucketMemberships.status, "active"),
|
|
341
|
+
),
|
|
342
|
+
);
|
|
343
|
+
logger.info("Bucket leave deferred by minDwell", {
|
|
344
|
+
bucketId: bucket.id,
|
|
345
|
+
userId,
|
|
346
|
+
deferUntil: deadline.toISOString(),
|
|
347
|
+
});
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Compare-and-swap: only the emitter whose UPDATE actually flips the active row
|
|
352
|
+
// emits. A concurrent emitter that already flipped it matches zero rows → no
|
|
353
|
+
// emit (Section 6.3).
|
|
354
|
+
const left = await db
|
|
355
|
+
.update(bucketMemberships)
|
|
356
|
+
.set({
|
|
357
|
+
status: "left",
|
|
358
|
+
leftAt: new Date(),
|
|
359
|
+
lastEvaluatedAt: new Date(),
|
|
360
|
+
updatedAt: new Date(),
|
|
361
|
+
})
|
|
362
|
+
.where(
|
|
363
|
+
and(
|
|
364
|
+
eq(bucketMemberships.id, active.id),
|
|
365
|
+
eq(bucketMemberships.status, "active"),
|
|
366
|
+
),
|
|
367
|
+
)
|
|
368
|
+
.returning({
|
|
369
|
+
id: bucketMemberships.id,
|
|
370
|
+
entryCount: bucketMemberships.entryCount,
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
const flipped = left[0];
|
|
374
|
+
if (!flipped) {
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
await emitBucketTransition({
|
|
379
|
+
db,
|
|
380
|
+
registry,
|
|
381
|
+
hatchet,
|
|
382
|
+
logger,
|
|
383
|
+
kind: "left",
|
|
384
|
+
bucket,
|
|
385
|
+
userId,
|
|
386
|
+
userEmail,
|
|
387
|
+
epoch: flipped.entryCount,
|
|
388
|
+
source: "event",
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
return { bucketId: bucket.id, transition: "left" };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* The `reentry` emit gate, consulted on the JOIN transition only (Section 6.3).
|
|
396
|
+
* Suppressing the emit still wrote the active row and advanced the epoch — only
|
|
397
|
+
* the `bucket:entered` ingestEvent recursion is skipped.
|
|
398
|
+
*/
|
|
399
|
+
function shouldEmitJoin(bucket: BucketMeta, priorCount: number): boolean {
|
|
400
|
+
// First-ever join always emits.
|
|
401
|
+
if (priorCount === 0) return true;
|
|
402
|
+
switch (bucket.reentry ?? "unlimited") {
|
|
403
|
+
case "unlimited":
|
|
404
|
+
return true;
|
|
405
|
+
case "once":
|
|
406
|
+
// Any prior membership → suppress (mirrors checkEntryLimit "once").
|
|
407
|
+
return false;
|
|
408
|
+
case "once_per_period":
|
|
409
|
+
// Conservative without re-reading prior rows here: a per-period emit gate
|
|
410
|
+
// needs the most-recent prior transition timestamp. The active row was
|
|
411
|
+
// just inserted; the prior-row timestamps are not loaded on this path, so
|
|
412
|
+
// we treat once_per_period as "emit" once the active insert won and let
|
|
413
|
+
// the journey-side entryLimit/entryPeriod enforce cooldown (the documented
|
|
414
|
+
// two-layer gating, Section 13). A precise prior-transition lookup is a
|
|
415
|
+
// later-phase refinement.
|
|
416
|
+
return true;
|
|
417
|
+
default:
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** True while the active membership is still inside its minDwell window. */
|
|
423
|
+
function withinMinDwell(
|
|
424
|
+
active: typeof bucketMemberships.$inferSelect,
|
|
425
|
+
bucket: BucketMeta,
|
|
426
|
+
): boolean {
|
|
427
|
+
if (!bucket.minDwell) return false;
|
|
428
|
+
const elapsed = Date.now() - active.enteredAt.getTime();
|
|
429
|
+
return elapsed < durationToMs(bucket.minDwell);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Arm the shared per-user fast-expiry durable timer by pushing a
|
|
434
|
+
* `bucket:arm-expiry` event (Section 6.5). The single shared `bucketExpiryTask`
|
|
435
|
+
* durableTask (workflows/bucket-reconcile.ts) routes on `onEvents:
|
|
436
|
+
* ["bucket:arm-expiry"]`, durably sleeps to the deadline, then leaves via a CAS
|
|
437
|
+
* keyed on the ARMED `expiresAt`. The `bucket:`-prefixed event is recursion-guarded
|
|
438
|
+
* by `checkBucketMembership`, so arming does NOT re-enter bucket evaluation.
|
|
439
|
+
* Best-effort: the cron is the authoritative backstop, so a push failure is logged
|
|
440
|
+
* and swallowed.
|
|
441
|
+
*/
|
|
442
|
+
async function armExpiryTimer(opts: {
|
|
443
|
+
hatchet: HatchetClient;
|
|
444
|
+
logger: Logger;
|
|
445
|
+
bucket: BucketMeta;
|
|
446
|
+
rowId: string;
|
|
447
|
+
userId: string;
|
|
448
|
+
userEmail: string | null;
|
|
449
|
+
expiresAt: Date;
|
|
450
|
+
}): Promise<void> {
|
|
451
|
+
const { hatchet, logger, bucket, rowId, userId, userEmail, expiresAt } = opts;
|
|
452
|
+
const msUntilExpiry = Math.max(0, expiresAt.getTime() - Date.now());
|
|
453
|
+
try {
|
|
454
|
+
await hatchet.events.push("bucket:arm-expiry", {
|
|
455
|
+
rowId,
|
|
456
|
+
bucketId: bucket.id,
|
|
457
|
+
userId,
|
|
458
|
+
userEmail,
|
|
459
|
+
armedExpiresAt: expiresAt.toISOString(),
|
|
460
|
+
msUntilExpiry,
|
|
461
|
+
});
|
|
462
|
+
} catch (err) {
|
|
463
|
+
logger.warn("Bucket fast-expiry arm failed (cron backstop covers it)", {
|
|
464
|
+
bucketId: bucket.id,
|
|
465
|
+
userId,
|
|
466
|
+
error: err instanceof Error ? err.message : String(err),
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* The persisted membership-expiry / fastExpiry arming epoch. Non-time-based,
|
|
473
|
+
* non-fastExpiry buckets have no deadline (returns null). Time-based / fastExpiry
|
|
474
|
+
* buckets that carry a single `within` window get `now + within`; the reconcile
|
|
475
|
+
* cron (Phase 2) and fastExpiry timer (Phase 3) own the actual leave.
|
|
476
|
+
*/
|
|
477
|
+
function computeExpiresAt(bucket: BucketMeta): Date | null {
|
|
478
|
+
if (!bucket.criteria) return null;
|
|
479
|
+
if (!bucket.timeBased && !bucket.fastExpiry) return null;
|
|
480
|
+
const within = firstWithin(bucket.criteria);
|
|
481
|
+
if (!within) return null;
|
|
482
|
+
return new Date(Date.now() + durationToMs(within));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Find the first EventCondition.within in a criteria tree (depth-first). */
|
|
486
|
+
function firstWithin(
|
|
487
|
+
criteria: NonNullable<BucketMeta["criteria"]>,
|
|
488
|
+
): NonNullable<BucketMeta["minDwell"]> | null {
|
|
489
|
+
if (criteria.type === "event" && criteria.within) {
|
|
490
|
+
return criteria.within;
|
|
491
|
+
}
|
|
492
|
+
if (criteria.type === "composite") {
|
|
493
|
+
for (const child of criteria.conditions) {
|
|
494
|
+
const found = firstWithin(child);
|
|
495
|
+
if (found) return found;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { BucketMeta } from "@hogsend/core/types";
|
|
2
|
+
import type { hatchet } from "../lib/hatchet.js";
|
|
3
|
+
|
|
4
|
+
export interface DefinedBucket {
|
|
5
|
+
meta: BucketMeta;
|
|
6
|
+
/**
|
|
7
|
+
* The only task a bucket ever holds is the opt-in per-user fast-expiry timer,
|
|
8
|
+
* which is a DURABLE task (it `ctx.sleepFor`s — Section 6.5), so the type MUST
|
|
9
|
+
* be the durableTask return type, mirroring
|
|
10
|
+
* `DefinedJourney.task = ReturnType<typeof hatchet.durableTask>`
|
|
11
|
+
* (define-journey.ts:34) — NOT `hatchet.task`. The common case is
|
|
12
|
+
* declarative-only (no task), like webhookSources; the engine-wide
|
|
13
|
+
* `bucketReconcileTask` handles time-based leaves regardless.
|
|
14
|
+
*/
|
|
15
|
+
task?: ReturnType<typeof hatchet.durableTask>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function defineBucket(options: { meta: BucketMeta }): DefinedBucket {
|
|
19
|
+
// bucketMetaSchema.parse happens at BucketRegistry.register (the journey
|
|
20
|
+
// precedent). defineBucket stays a PURE passthrough — identical in shape to
|
|
21
|
+
// defineWebhookSource (define-webhook-source.ts:30-34) — and does NOT branch
|
|
22
|
+
// on meta or construct any task. This keeps the three primitives consistent
|
|
23
|
+
// and avoids building a Hatchet durableTask at module-load before validation
|
|
24
|
+
// has run. The fast-expiry durableTask is synthesized later, at worker build,
|
|
25
|
+
// by selectBucketTasks(buckets, enabled) reading meta.fastExpiry (Section 9.4)
|
|
26
|
+
// — that is the single place a bucket's task is constructed, AFTER the
|
|
27
|
+
// registry has validated the meta.
|
|
28
|
+
return { meta: options.meta };
|
|
29
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { BucketRegistry } from "@hogsend/core/registry";
|
|
2
|
+
|
|
3
|
+
let _registry: BucketRegistry | undefined;
|
|
4
|
+
|
|
5
|
+
export function setBucketRegistry(registry: BucketRegistry): void {
|
|
6
|
+
_registry = registry;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getBucketRegistrySingleton(): BucketRegistry {
|
|
10
|
+
if (!_registry) {
|
|
11
|
+
throw new Error(
|
|
12
|
+
"Bucket registry not initialized. Call setBucketRegistry() at startup.",
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
return _registry;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Reset the singleton — only for test cleanup. */
|
|
19
|
+
export function resetBucketRegistry(): void {
|
|
20
|
+
_registry = undefined;
|
|
21
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { BucketRegistry } from "@hogsend/core/registry";
|
|
2
|
+
import { parseEnabledFilter } from "../journeys/registry.js";
|
|
3
|
+
import { bucketExpiryTask } from "../workflows/bucket-reconcile.js";
|
|
4
|
+
import type { DefinedBucket } from "./define-bucket.js";
|
|
5
|
+
import { setBucketRegistry } from "./registry-singleton.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Build a {@link BucketRegistry} from an injected array of buckets, applying the
|
|
9
|
+
* enabled filter, and install it as the process singleton (so the real-time
|
|
10
|
+
* ingest path and the reconcile cron can resolve it). Returns the registry.
|
|
11
|
+
*
|
|
12
|
+
* `parseEnabledFilter` (journeys/registry.ts) is reused as-is — `ENABLED_BUCKETS`
|
|
13
|
+
* honours the same `"*"`-or-csv contract as `ENABLED_JOURNEYS` (Section 9.3).
|
|
14
|
+
* `BucketRegistry.register()` runs `bucketMetaSchema.parse()` internally, so no
|
|
15
|
+
* separate validation step is needed here.
|
|
16
|
+
*/
|
|
17
|
+
export function buildBucketRegistry(
|
|
18
|
+
buckets: DefinedBucket[],
|
|
19
|
+
enabledFilter?: string,
|
|
20
|
+
): BucketRegistry {
|
|
21
|
+
const registry = new BucketRegistry();
|
|
22
|
+
const enabled = parseEnabledFilter(enabledFilter);
|
|
23
|
+
|
|
24
|
+
for (const bucket of buckets) {
|
|
25
|
+
if (enabled === "*" || enabled.has(bucket.meta.id)) {
|
|
26
|
+
registry.register(bucket.meta);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
setBucketRegistry(registry);
|
|
31
|
+
return registry;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Select the Hatchet durable tasks for the enabled buckets. This is the SINGLE
|
|
36
|
+
* place a bucket's per-user fast-expiry timer task is constructed (Section
|
|
37
|
+
* 4.3/9.4) — task construction happens at worker build, AFTER the registry has
|
|
38
|
+
* validated every meta, never at module-load.
|
|
39
|
+
*
|
|
40
|
+
* Only `meta.fastExpiry` buckets contribute a task; the engine-wide
|
|
41
|
+
* `bucketReconcileTask` (registered separately in `baseWorkflows`) owns
|
|
42
|
+
* time-based leaves regardless. The fast-expiry timer is a single shared
|
|
43
|
+
* `durableTask` keyed on `bucket:arm-expiry` (per-bucket arming is by event
|
|
44
|
+
* payload, not per-bucket task instances), so it is registered once if ANY
|
|
45
|
+
* enabled bucket opts in.
|
|
46
|
+
*/
|
|
47
|
+
export function selectBucketTasks(
|
|
48
|
+
buckets: DefinedBucket[],
|
|
49
|
+
enabledFilter?: string,
|
|
50
|
+
): NonNullable<DefinedBucket["task"]>[] {
|
|
51
|
+
const enabled = parseEnabledFilter(enabledFilter);
|
|
52
|
+
const hasFastExpiry = buckets.some(
|
|
53
|
+
(b) =>
|
|
54
|
+
(enabled === "*" || enabled.has(b.meta.id)) && b.meta.fastExpiry === true,
|
|
55
|
+
);
|
|
56
|
+
if (!hasFastExpiry) return [];
|
|
57
|
+
|
|
58
|
+
// The single shared `bucket:arm-expiry` durableTask, registered ONCE because
|
|
59
|
+
// any enabled bucket opts in (per-bucket arming is by event payload). Cast to
|
|
60
|
+
// the DefinedBucket task shape — both are `hatchet.durableTask` returns.
|
|
61
|
+
return [bucketExpiryTask as NonNullable<DefinedBucket["task"]>];
|
|
62
|
+
}
|