@hogsend/engine 0.5.0 → 0.6.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 +1 -0
- package/src/buckets/define-bucket.ts +79 -8
- package/src/buckets/registry.ts +81 -0
- package/src/container.ts +37 -5
- package/src/index.ts +14 -0
- package/src/lib/boot.ts +11 -1
- package/src/lib/bucket-emit.ts +47 -5
- package/src/routes/admin/buckets.ts +39 -9
- package/src/worker.ts +19 -2
- package/src/workflows/bucket-backfill.ts +90 -1
- package/src/workflows/bucket-reconcile.ts +205 -7
package/src/container.ts
CHANGED
|
@@ -14,8 +14,12 @@ import {
|
|
|
14
14
|
createResendProvider,
|
|
15
15
|
} from "@hogsend/plugin-resend";
|
|
16
16
|
import type { Resend } from "resend";
|
|
17
|
+
import { createBucketAccessor } from "./buckets/bucket-access.js";
|
|
17
18
|
import type { DefinedBucket } from "./buckets/define-bucket.js";
|
|
18
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
buildBucketRegistry,
|
|
21
|
+
collectBucketReactionJourneys,
|
|
22
|
+
} from "./buckets/registry.js";
|
|
19
23
|
import { env } from "./env.js";
|
|
20
24
|
import { setClientScheduleDefaults } from "./journeys/client-defaults-singleton.js";
|
|
21
25
|
import type { DefinedJourney } from "./journeys/define-journey.js";
|
|
@@ -200,10 +204,38 @@ export function createHogsendClient(
|
|
|
200
204
|
// Installs the bucket registry singleton in BOTH the API and worker processes
|
|
201
205
|
// (both call createHogsendClient); the real-time ingest path reads it via
|
|
202
206
|
// getBucketRegistrySingleton().
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
+
const buckets = opts.buckets ?? [];
|
|
208
|
+
const enabledBuckets = opts.enabledBuckets ?? env.ENABLED_BUCKETS;
|
|
209
|
+
const bucketRegistry = buildBucketRegistry(buckets, enabledBuckets);
|
|
210
|
+
|
|
211
|
+
// Register the reaction journeys generated by `bucket.on()` into the journey
|
|
212
|
+
// registry AFTER buildJourneyRegistry, bypassing the ENABLED_JOURNEYS filter:
|
|
213
|
+
// reactions are bucket-owned and were already gated by ENABLED_BUCKETS
|
|
214
|
+
// (collectBucketReactionJourneys), so their `bucket-<id>-on-<kind>` ids must NOT
|
|
215
|
+
// be subject to the journeys csv (Section 9). Both API and worker call
|
|
216
|
+
// createHogsendClient, so the singleton carries reaction metas in both
|
|
217
|
+
// processes (needed for admin feedsJourneys + the dwell-cron lookup).
|
|
218
|
+
for (const reaction of collectBucketReactionJourneys(
|
|
219
|
+
buckets,
|
|
220
|
+
enabledBuckets,
|
|
221
|
+
)) {
|
|
222
|
+
registry.register(reaction.meta);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Re-bind the member-access accessors on each enabled bucket to THIS
|
|
226
|
+
// container's db so `overrides.db` flows through (the accessors default to the
|
|
227
|
+
// getDb() singleton at defineBucket time, before any container exists —
|
|
228
|
+
// bucket-access.ts dbResolver seam). The enabled set mirrors
|
|
229
|
+
// buildBucketRegistry's filter.
|
|
230
|
+
const enabledIds = new Set(bucketRegistry.getAll().map((b) => b.id));
|
|
231
|
+
for (const bucket of buckets) {
|
|
232
|
+
if (!enabledIds.has(bucket.meta.id)) continue;
|
|
233
|
+
const accessor = createBucketAccessor(bucket.meta.id, () => db);
|
|
234
|
+
bucket.count = accessor.count;
|
|
235
|
+
bucket.has = accessor.has;
|
|
236
|
+
bucket.members = accessor.members;
|
|
237
|
+
bucket.membersIterator = accessor.membersIterator;
|
|
238
|
+
}
|
|
207
239
|
|
|
208
240
|
const provider =
|
|
209
241
|
opts.email?.provider ??
|
package/src/index.ts
CHANGED
|
@@ -39,6 +39,18 @@ export {
|
|
|
39
39
|
// --- App / container / worker factories ---
|
|
40
40
|
export { type AppEnv, type CreateAppOptions, createApp } from "./app.js";
|
|
41
41
|
// --- Buckets ---
|
|
42
|
+
export {
|
|
43
|
+
type BucketAccessor,
|
|
44
|
+
type BucketMemberRow,
|
|
45
|
+
createBucketAccessor,
|
|
46
|
+
type MembersResult,
|
|
47
|
+
} from "./buckets/bucket-access.js";
|
|
48
|
+
export type {
|
|
49
|
+
BucketLeaveReason,
|
|
50
|
+
DwellOptions,
|
|
51
|
+
EnterOptions,
|
|
52
|
+
LeaveOptions,
|
|
53
|
+
} from "./buckets/bucket-reactions.js";
|
|
42
54
|
export {
|
|
43
55
|
type BucketTransition,
|
|
44
56
|
type BucketTransitionKind,
|
|
@@ -50,6 +62,8 @@ export {
|
|
|
50
62
|
} from "./buckets/define-bucket.js";
|
|
51
63
|
export {
|
|
52
64
|
buildBucketRegistry,
|
|
65
|
+
collectBucketReactionJourneys,
|
|
66
|
+
selectBucketReactionTasks,
|
|
53
67
|
selectBucketTasks,
|
|
54
68
|
} from "./buckets/registry.js";
|
|
55
69
|
export {
|
package/src/lib/boot.ts
CHANGED
|
@@ -125,12 +125,20 @@ export interface WorkerReadyInfo {
|
|
|
125
125
|
client: HogsendClient;
|
|
126
126
|
journeyTasks: number;
|
|
127
127
|
bucketTasks: number;
|
|
128
|
+
/** Reaction journey tasks generated by `bucket.on()` (Section 9). */
|
|
129
|
+
bucketReactionTasks: number;
|
|
128
130
|
builtinTasks: number;
|
|
129
131
|
}
|
|
130
132
|
|
|
131
133
|
/** Render the worker "ready" output (banner in dev TTY, structured log otherwise). */
|
|
132
134
|
export function reportWorkerReady(info: WorkerReadyInfo): void {
|
|
133
|
-
const {
|
|
135
|
+
const {
|
|
136
|
+
client,
|
|
137
|
+
journeyTasks,
|
|
138
|
+
bucketTasks,
|
|
139
|
+
bucketReactionTasks,
|
|
140
|
+
builtinTasks,
|
|
141
|
+
} = info;
|
|
134
142
|
const engineVersion = getEngineVersion();
|
|
135
143
|
const hatchetHost = client.env.HATCHET_CLIENT_HOST_PORT;
|
|
136
144
|
|
|
@@ -141,6 +149,7 @@ export function reportWorkerReady(info: WorkerReadyInfo): void {
|
|
|
141
149
|
namespace: client.env.HATCHET_CLIENT_NAMESPACE || undefined,
|
|
142
150
|
journeyTasks,
|
|
143
151
|
bucketTasks,
|
|
152
|
+
bucketReactionTasks,
|
|
144
153
|
builtinTasks,
|
|
145
154
|
});
|
|
146
155
|
return;
|
|
@@ -151,6 +160,7 @@ export function reportWorkerReady(info: WorkerReadyInfo): void {
|
|
|
151
160
|
const tasks = [
|
|
152
161
|
plural(journeyTasks, "journey task"),
|
|
153
162
|
plural(bucketTasks, "bucket task"),
|
|
163
|
+
plural(bucketReactionTasks, "reaction task"),
|
|
154
164
|
plural(builtinTasks, "built-in task"),
|
|
155
165
|
].join(dim(" · "));
|
|
156
166
|
|
package/src/lib/bucket-emit.ts
CHANGED
|
@@ -2,11 +2,12 @@ import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
|
|
|
2
2
|
import type { BucketMeta } from "@hogsend/core";
|
|
3
3
|
import type { JourneyRegistry } from "@hogsend/core/registry";
|
|
4
4
|
import type { Database } from "@hogsend/db";
|
|
5
|
+
import type { BucketLeaveReason } from "../buckets/bucket-reactions.js";
|
|
5
6
|
import { syncBucketToPostHog } from "./bucket-posthog-sync.js";
|
|
6
7
|
import { ingestEvent } from "./ingestion.js";
|
|
7
8
|
import type { Logger } from "./logger.js";
|
|
8
9
|
|
|
9
|
-
export type BucketTransitionKind = "entered" | "left";
|
|
10
|
+
export type BucketTransitionKind = "entered" | "left" | "dwell";
|
|
10
11
|
|
|
11
12
|
/** Where a transition originated — carried on the emitted event properties. */
|
|
12
13
|
export type BucketTransitionSource =
|
|
@@ -40,6 +41,16 @@ export async function emitBucketTransition(opts: {
|
|
|
40
41
|
userEmail: string | null;
|
|
41
42
|
epoch: number;
|
|
42
43
|
source?: BucketTransitionSource;
|
|
44
|
+
/** Carried on a `left` transition's properties → `ctx.reason`. */
|
|
45
|
+
reason?: BucketLeaveReason;
|
|
46
|
+
/** The dwell schedule label (`after-<ms>`/`every-<ms>`) — `dwell` only. */
|
|
47
|
+
dwellLabel?: string;
|
|
48
|
+
/**
|
|
49
|
+
* The deterministic dwell interval ordinal — `dwell` only. Rides the
|
|
50
|
+
* idempotencyKey so a same-sweep retry recomputes the identical key and is
|
|
51
|
+
* absorbed by the `userEvents` dedup. Surfaced as `dwellCount`.
|
|
52
|
+
*/
|
|
53
|
+
dwellOrdinal?: number;
|
|
43
54
|
}): Promise<void> {
|
|
44
55
|
const {
|
|
45
56
|
db,
|
|
@@ -52,22 +63,53 @@ export async function emitBucketTransition(opts: {
|
|
|
52
63
|
userEmail,
|
|
53
64
|
epoch,
|
|
54
65
|
source = "event",
|
|
66
|
+
reason,
|
|
67
|
+
dwellLabel,
|
|
68
|
+
dwellOrdinal,
|
|
55
69
|
} = opts;
|
|
56
70
|
|
|
71
|
+
// The dwell transition emits a labelled event so two dwell reactions on one
|
|
72
|
+
// bucket (one `after`, one `every`) route distinctly; enter/left keep the
|
|
73
|
+
// canonical `bucket:<kind>:<id>` form. The idempotencyKey is recomputed
|
|
74
|
+
// identically by a retry: enter/left key on the membership epoch, dwell keys
|
|
75
|
+
// on the (label, ordinal) so a same-sweep retry rides the userEvents dedup.
|
|
76
|
+
const eventName =
|
|
77
|
+
kind === "dwell"
|
|
78
|
+
? `bucket:dwell:${bucket.id}:${dwellLabel}`
|
|
79
|
+
: `bucket:${kind}:${bucket.id}`;
|
|
80
|
+
const idempotencyKey =
|
|
81
|
+
kind === "dwell"
|
|
82
|
+
? `bucket:${bucket.id}:${userId}:dwell:${dwellLabel}:${dwellOrdinal}`
|
|
83
|
+
: `bucket:${bucket.id}:${userId}:${kind}:${epoch}`;
|
|
84
|
+
|
|
57
85
|
const properties: Record<string, unknown> = {
|
|
58
86
|
bucketId: bucket.id,
|
|
59
87
|
bucketName: bucket.name,
|
|
60
88
|
userId,
|
|
61
89
|
transition: kind,
|
|
62
90
|
source,
|
|
91
|
+
// entryCount is always carried (the membership ordinal); the reaction `run`
|
|
92
|
+
// derives `isFirstEntry` from it.
|
|
93
|
+
entryCount: epoch,
|
|
63
94
|
};
|
|
95
|
+
// reason is carried on a leave so the `leave` reaction can filter on it.
|
|
96
|
+
if (kind === "left" && reason != null) {
|
|
97
|
+
properties.reason = reason;
|
|
98
|
+
}
|
|
99
|
+
// dwellCount = the interval ordinal, surfaced to the dwell reaction.
|
|
100
|
+
if (kind === "dwell" && dwellOrdinal != null) {
|
|
101
|
+
properties.dwellCount = dwellOrdinal;
|
|
102
|
+
}
|
|
64
103
|
|
|
65
104
|
// Optional PostHog person-property mirror (Section 12). Off by default; a
|
|
66
105
|
// no-op without POSTHOG_API_KEY. Wired here, the single transition path shared
|
|
67
106
|
// by all three producers (real-time / reconcile / fast-expiry), so the sync
|
|
68
107
|
// fires exactly once per emitted transition. Best-effort — it never blocks the
|
|
69
|
-
// event emit below.
|
|
70
|
-
|
|
108
|
+
// event emit below. Dwell is a recurring membership-age tick, not a state
|
|
109
|
+
// change, so it does NOT mirror a person property.
|
|
110
|
+
if (kind === "entered" || kind === "left") {
|
|
111
|
+
syncBucketToPostHog({ logger, kind, bucket, userId });
|
|
112
|
+
}
|
|
71
113
|
|
|
72
114
|
// Per-bucket alias — the recommended, narrowly-routed binding. The
|
|
73
115
|
// deterministic idempotencyKey rides the userEvents dedup short-circuit as
|
|
@@ -78,11 +120,11 @@ export async function emitBucketTransition(opts: {
|
|
|
78
120
|
hatchet,
|
|
79
121
|
logger,
|
|
80
122
|
event: {
|
|
81
|
-
event:
|
|
123
|
+
event: eventName,
|
|
82
124
|
userId,
|
|
83
125
|
userEmail: userEmail ?? "",
|
|
84
126
|
properties,
|
|
85
|
-
idempotencyKey
|
|
127
|
+
idempotencyKey,
|
|
86
128
|
},
|
|
87
129
|
});
|
|
88
130
|
|
|
@@ -123,6 +123,8 @@ const getRoute = createRoute({
|
|
|
123
123
|
id: z.string(),
|
|
124
124
|
name: z.string(),
|
|
125
125
|
trigger: z.string(),
|
|
126
|
+
sourceBucketId: z.string().nullable(),
|
|
127
|
+
owned: z.boolean(),
|
|
126
128
|
}),
|
|
127
129
|
),
|
|
128
130
|
recentMembers: z.array(memberSchema),
|
|
@@ -332,27 +334,55 @@ export const bucketsRouter = new OpenAPIHono<AppEnv>()
|
|
|
332
334
|
counts[row.status as keyof typeof emptyCounts] = row.count;
|
|
333
335
|
}
|
|
334
336
|
|
|
335
|
-
// Which journeys this bucket feeds
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
// bucket's
|
|
337
|
+
// Which journeys this bucket feeds. Two sources, owned-first:
|
|
338
|
+
// 1. Owned reactions — journeys generated by `bucket.on()`, tagged with
|
|
339
|
+
// `sourceBucketId === id`. Discovered by scanning the registry; surfaced
|
|
340
|
+
// with `owned: true`.
|
|
341
|
+
// 2. External bindings — hand-written journeys bound to the bucket's emitted
|
|
342
|
+
// transition events via the per-bucket alias `bucket:entered:<id>` (the
|
|
343
|
+
// recommended, narrowly-routed binding) or the generic `bucket:entered`.
|
|
344
|
+
// Surfaced with `owned: false`. Owned wins on collision.
|
|
345
|
+
const feedsMap = new Map<
|
|
346
|
+
string,
|
|
347
|
+
{
|
|
348
|
+
id: string;
|
|
349
|
+
name: string;
|
|
350
|
+
trigger: string;
|
|
351
|
+
sourceBucketId: string | null;
|
|
352
|
+
owned: boolean;
|
|
353
|
+
}
|
|
354
|
+
>();
|
|
355
|
+
|
|
356
|
+
// Owned reactions: scan the journey registry for sourceBucketId === id.
|
|
357
|
+
for (const journey of registry
|
|
358
|
+
.getAll()
|
|
359
|
+
.filter((j) => j.sourceBucketId === id)) {
|
|
360
|
+
feedsMap.set(journey.id, {
|
|
361
|
+
id: journey.id,
|
|
362
|
+
name: journey.name,
|
|
363
|
+
trigger: journey.trigger.event,
|
|
364
|
+
sourceBucketId: id,
|
|
365
|
+
owned: true,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// External bindings: the existing alias + generic cross-reference. Skip any
|
|
370
|
+
// journey already surfaced as owned (owned wins).
|
|
340
371
|
const feedEvents = [
|
|
341
372
|
`bucket:entered:${id}`,
|
|
342
373
|
`bucket:left:${id}`,
|
|
343
374
|
"bucket:entered",
|
|
344
375
|
"bucket:left",
|
|
345
376
|
];
|
|
346
|
-
const feedsMap = new Map<
|
|
347
|
-
string,
|
|
348
|
-
{ id: string; name: string; trigger: string }
|
|
349
|
-
>();
|
|
350
377
|
for (const evt of feedEvents) {
|
|
351
378
|
for (const journey of registry.getByTriggerEvent(evt)) {
|
|
379
|
+
if (feedsMap.has(journey.id)) continue;
|
|
352
380
|
feedsMap.set(journey.id, {
|
|
353
381
|
id: journey.id,
|
|
354
382
|
name: journey.name,
|
|
355
383
|
trigger: evt,
|
|
384
|
+
sourceBucketId: journey.sourceBucketId ?? null,
|
|
385
|
+
owned: false,
|
|
356
386
|
});
|
|
357
387
|
}
|
|
358
388
|
}
|
package/src/worker.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { DefinedBucket } from "./buckets/define-bucket.js";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
selectBucketReactionTasks,
|
|
4
|
+
selectBucketTasks,
|
|
5
|
+
} from "./buckets/registry.js";
|
|
3
6
|
import type { HogsendClient } from "./container.js";
|
|
4
7
|
import type { DefinedJourney } from "./journeys/define-journey.js";
|
|
5
8
|
import { selectJourneyTasks } from "./journeys/registry.js";
|
|
@@ -46,6 +49,15 @@ export function createWorker(opts: CreateWorkerOptions): Worker {
|
|
|
46
49
|
// reconcile cron (bucketReconcileTask) is ALWAYS registered in baseWorkflows
|
|
47
50
|
// below (Section 10), regardless of fastExpiry.
|
|
48
51
|
const bucketTasks = selectBucketTasks(opts.buckets ?? [], enabledBuckets);
|
|
52
|
+
// Reaction journeys generated by `bucket.on()` desugar to real durable tasks.
|
|
53
|
+
// They are bucket-owned, so they are gated by ENABLED_BUCKETS (NOT
|
|
54
|
+
// ENABLED_JOURNEYS) and wired directly here rather than via the journeys[]
|
|
55
|
+
// array (Section 9). Throws loudly on a reaction-id collision.
|
|
56
|
+
const bucketReactionTasks = selectBucketReactionTasks(
|
|
57
|
+
opts.buckets ?? [],
|
|
58
|
+
enabledBuckets,
|
|
59
|
+
journeys.map((j) => j.meta.id),
|
|
60
|
+
);
|
|
49
61
|
|
|
50
62
|
const baseWorkflows = [
|
|
51
63
|
sendEmailTask,
|
|
@@ -55,6 +67,7 @@ export function createWorker(opts: CreateWorkerOptions): Worker {
|
|
|
55
67
|
bucketBackfillTask,
|
|
56
68
|
...journeyTasks,
|
|
57
69
|
...bucketTasks,
|
|
70
|
+
...bucketReactionTasks,
|
|
58
71
|
];
|
|
59
72
|
const workflows = [
|
|
60
73
|
...baseWorkflows,
|
|
@@ -94,8 +107,12 @@ export function createWorker(opts: CreateWorkerOptions): Worker {
|
|
|
94
107
|
client: container,
|
|
95
108
|
journeyTasks: journeyTasks.length,
|
|
96
109
|
bucketTasks: bucketTasks.length,
|
|
110
|
+
bucketReactionTasks: bucketReactionTasks.length,
|
|
97
111
|
builtinTasks:
|
|
98
|
-
baseWorkflows.length -
|
|
112
|
+
baseWorkflows.length -
|
|
113
|
+
journeyTasks.length -
|
|
114
|
+
bucketTasks.length -
|
|
115
|
+
bucketReactionTasks.length,
|
|
99
116
|
});
|
|
100
117
|
|
|
101
118
|
// Publish liveness so the API + Studio can show "worker connected"
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
importJobs,
|
|
16
16
|
userEvents,
|
|
17
17
|
} from "@hogsend/db";
|
|
18
|
-
import { and, eq, gt, gte, inArray, isNull, sql } from "drizzle-orm";
|
|
18
|
+
import { and, eq, gt, gte, inArray, isNull, max, sql } from "drizzle-orm";
|
|
19
19
|
import {
|
|
20
20
|
computeExpiresAt,
|
|
21
21
|
computeMaxDwellAt,
|
|
@@ -209,6 +209,18 @@ async function backfillJoins(opts: {
|
|
|
209
209
|
// unset value would never be force-left.
|
|
210
210
|
const maxDwellAt = computeMaxDwellAt(bucket);
|
|
211
211
|
|
|
212
|
+
// Historical dwell anchor (Section 6.3 / LOCKED DECISION 1). For a
|
|
213
|
+
// windowed/event criterion the anchor is `max(occurredAt)` of the qualifying
|
|
214
|
+
// event = "when they became dormant" (e.g. went-dormant = the last
|
|
215
|
+
// `app_opened`). The dwell gate reads `coalesce(dwellAnchorAt, enteredAt)`, so
|
|
216
|
+
// backfilled members start the dwell clock at their real historical instant
|
|
217
|
+
// rather than the deploy-time `enteredAt`. Shapes with no cheap per-matcher
|
|
218
|
+
// timestamp leave the anchor NULL (fall back to enteredAt). The live join path
|
|
219
|
+
// (handleJoin) never sets dwellAnchorAt, so post-deploy joins clock from their
|
|
220
|
+
// real enteredAt. Computed batched per chunk (one GROUP BY max(occurredAt),
|
|
221
|
+
// mirroring the priorCounts GROUP BY) — never per-user serial queries.
|
|
222
|
+
const anchorEvent = resolveDwellAnchorEvent(criteria);
|
|
223
|
+
|
|
212
224
|
// Fix C (DEFERRED): backfilled fastExpiry rows are NOT armed with a
|
|
213
225
|
// bucket:arm-expiry durable timer here — they are picked up by the next cron
|
|
214
226
|
// sweep instead (reconcileBucketLeaves / reconcileBucketTtlLeaves are the
|
|
@@ -253,6 +265,35 @@ async function backfillJoins(opts: {
|
|
|
253
265
|
priorCounts.map((r) => [r.userId, Number(r.cnt)]),
|
|
254
266
|
);
|
|
255
267
|
|
|
268
|
+
// Batched dwell-anchor derivation (LOCKED DECISION 1): one GROUP BY
|
|
269
|
+
// max(occurredAt) over the qualifying event for THIS chunk, mirroring the
|
|
270
|
+
// priorCounts GROUP BY above (never per-user serial queries). Only computed
|
|
271
|
+
// when the criteria shape exposes a cheap per-matcher anchor event; an empty
|
|
272
|
+
// map leaves dwellAnchorAt NULL → the dwell gate falls back to enteredAt.
|
|
273
|
+
let anchorByUser = new Map<string, Date>();
|
|
274
|
+
if (anchorEvent != null) {
|
|
275
|
+
const anchors = await db
|
|
276
|
+
.select({
|
|
277
|
+
userId: userEvents.userId,
|
|
278
|
+
lastAt: max(userEvents.occurredAt),
|
|
279
|
+
})
|
|
280
|
+
.from(userEvents)
|
|
281
|
+
.where(
|
|
282
|
+
and(
|
|
283
|
+
eq(userEvents.event, anchorEvent),
|
|
284
|
+
inArray(userEvents.userId, chunk),
|
|
285
|
+
),
|
|
286
|
+
)
|
|
287
|
+
.groupBy(userEvents.userId);
|
|
288
|
+
anchorByUser = new Map(
|
|
289
|
+
anchors
|
|
290
|
+
.filter(
|
|
291
|
+
(r): r is { userId: string; lastAt: Date } => r.lastAt != null,
|
|
292
|
+
)
|
|
293
|
+
.map((r) => [r.userId, r.lastAt]),
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
256
297
|
const rows = chunk.map((userId) => ({
|
|
257
298
|
userId,
|
|
258
299
|
userEmail: emailByUser.get(userId) ?? null,
|
|
@@ -262,6 +303,8 @@ async function backfillJoins(opts: {
|
|
|
262
303
|
entryCount: 1 + (priorByUser.get(userId) ?? 0),
|
|
263
304
|
expiresAt: computeExpiresAt(bucket),
|
|
264
305
|
maxDwellAt,
|
|
306
|
+
// Historical dwell anchor where derivable; NULL otherwise (→ enteredAt).
|
|
307
|
+
dwellAnchorAt: anchorByUser.get(userId) ?? null,
|
|
265
308
|
lastEvaluatedAt: new Date(),
|
|
266
309
|
}));
|
|
267
310
|
|
|
@@ -365,6 +408,7 @@ async function reevalLeaves(opts: {
|
|
|
365
408
|
userEmail: row.userEmail,
|
|
366
409
|
epoch: row.entryCount,
|
|
367
410
|
source: "backfill",
|
|
411
|
+
reason: "criteria",
|
|
368
412
|
});
|
|
369
413
|
}
|
|
370
414
|
leftCount += flipped.length;
|
|
@@ -504,6 +548,51 @@ async function selectCompositeMatchers(
|
|
|
504
548
|
return matchers;
|
|
505
549
|
}
|
|
506
550
|
|
|
551
|
+
/**
|
|
552
|
+
* Resolve the event whose `max(occurredAt)` is the historical dwell anchor for a
|
|
553
|
+
* backfilled member (LOCKED DECISION 1 / Section 6.3) — "when they became
|
|
554
|
+
* dormant". Returns an event name only for the windowed/event shapes that expose
|
|
555
|
+
* a cheap per-matcher timestamp; `null` for everything else (the anchor stays
|
|
556
|
+
* NULL and the dwell gate falls back to `enteredAt`):
|
|
557
|
+
*
|
|
558
|
+
* - a single windowed `event` criterion → its `eventName` (the last qualifying
|
|
559
|
+
* occurrence is the window boundary, e.g. the last `app_opened`).
|
|
560
|
+
* - the lapsed-active composite `all(event(X).exists(),
|
|
561
|
+
* event(X).within(W).not_exists())` → event X (the flagship went-dormant
|
|
562
|
+
* shape; the last X is when they lapsed).
|
|
563
|
+
*
|
|
564
|
+
* Other shapes (property/count composites, OR-of-absence, multi-event) have no
|
|
565
|
+
* single cheap per-matcher timestamp, so they keep a NULL anchor.
|
|
566
|
+
*/
|
|
567
|
+
function resolveDwellAnchorEvent(criteria: ConditionEval): string | null {
|
|
568
|
+
if (criteria.type === "event") {
|
|
569
|
+
return criteria.within != null ? criteria.eventName : null;
|
|
570
|
+
}
|
|
571
|
+
// Lapsed-active composite — two legs on the SAME event X: an unwindowed
|
|
572
|
+
// exists() anchor and a windowed not_exists() leg. Mirrors
|
|
573
|
+
// isLapsedActiveComposite in bucket-reconcile.ts.
|
|
574
|
+
if (
|
|
575
|
+
criteria.type === "composite" &&
|
|
576
|
+
criteria.operator === "and" &&
|
|
577
|
+
criteria.conditions.length === 2
|
|
578
|
+
) {
|
|
579
|
+
const existsLeg = criteria.conditions.find(
|
|
580
|
+
(c) => c.type === "event" && c.check === "exists" && c.within == null,
|
|
581
|
+
);
|
|
582
|
+
const notExistsLeg = criteria.conditions.find(
|
|
583
|
+
(c) => c.type === "event" && c.check === "not_exists" && c.within != null,
|
|
584
|
+
);
|
|
585
|
+
if (
|
|
586
|
+
existsLeg?.type === "event" &&
|
|
587
|
+
notExistsLeg?.type === "event" &&
|
|
588
|
+
existsLeg.eventName === notExistsLeg.eventName
|
|
589
|
+
) {
|
|
590
|
+
return notExistsLeg.eventName;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
|
|
507
596
|
/**
|
|
508
597
|
* Upsert the bucket's current criteria fingerprint onto `bucket_configs` (Section
|
|
509
598
|
* 6.6 B). Mirrors the admin enable/disable onConflictDoUpdate target.
|