@pithy-sh/testers 0.1.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/LICENSE +21 -0
- package/README.md +17 -0
- package/docs/store-apis.md +107 -0
- package/package.json +62 -0
- package/pithy.manifest.json +52 -0
- package/src/activity/resolve.ts +273 -0
- package/src/audit/actions.ts +56 -0
- package/src/capability.ts +128 -0
- package/src/clock/days.ts +70 -0
- package/src/clock/replay.ts +190 -0
- package/src/cloudflare-test.d.ts +13 -0
- package/src/config/config.ts +518 -0
- package/src/crypto/token.ts +60 -0
- package/src/data/cohort.ts +83 -0
- package/src/data/enums.ts +134 -0
- package/src/data/event.ts +81 -0
- package/src/data/member.ts +79 -0
- package/src/data/snapshot.ts +280 -0
- package/src/data/tables.ts +49 -0
- package/src/error/errors.ts +229 -0
- package/src/health/score.ts +225 -0
- package/src/http/guards.ts +37 -0
- package/src/http/pages.ts +66 -0
- package/src/http/responses.ts +634 -0
- package/src/http/routes.ts +933 -0
- package/src/http/schemas.ts +210 -0
- package/src/http/scopes.ts +79 -0
- package/src/http/view.ts +304 -0
- package/src/index.ts +80 -0
- package/src/migrations/0001_cohorts.ts +202 -0
- package/src/nudge/cooldown.ts +104 -0
- package/src/nudge/copy.ts +179 -0
- package/src/nudge/enqueueSeam.ts +95 -0
- package/src/nudge/send.ts +89 -0
- package/src/projection/build.ts +285 -0
- package/src/projection/forecast.ts +348 -0
- package/src/projection/inputs.ts +63 -0
- package/src/projection/poissonBinomial.ts +91 -0
- package/src/projection/trend.ts +185 -0
- package/src/provision/provisionTesters.ts +109 -0
- package/src/provision/resolveTestersConfig.ts +155 -0
- package/src/roster/read.ts +227 -0
- package/src/roster/write.ts +511 -0
- package/src/seeds/example.ts +219 -0
- package/src/version.generated.ts +16 -0
- package/src/workflows/daily.ts +513 -0
- package/src/workflows/pass.ts +100 -0
- package/src/workflows/report.ts +52 -0
- package/src/workflows/retryPolicy.ts +48 -0
- package/src/workflows/specs.ts +73 -0
- package/src/workflows/worker.ts +132 -0
- package/src/workflows/wrangler.jsonc +66 -0
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
5
|
+
import { normalizeAddress } from "@pithy-sh/core/src/address/address";
|
|
6
|
+
import { chunkByBoundParameters } from "@pithy-sh/core/src/data/boundParameters";
|
|
7
|
+
import { messageOf } from "@pithy-sh/core/src/error/pithyError";
|
|
8
|
+
import type { Logger } from "@pithy-sh/core/src/logger/logger";
|
|
9
|
+
import type { ExpressionBuilder } from "kysely";
|
|
10
|
+
import { addDays, type DayKey, dayKey, daysSince, endOfDay } from "../clock/days";
|
|
11
|
+
import { readClock } from "../clock/replay";
|
|
12
|
+
import { isStoreOptInUrl, type TestersConfig } from "../config/config";
|
|
13
|
+
import { NudgeKind } from "../data/enums";
|
|
14
|
+
import type { TestersMember } from "../data/member";
|
|
15
|
+
import { type NudgeTally, TestersCohortSnapshot } from "../data/snapshot";
|
|
16
|
+
import { TESTERS_COHORTS_TABLE, TESTERS_SNAPSHOTS_TABLE, type TestersDatabase } from "../data/tables";
|
|
17
|
+
import { TestersCohortClosedError } from "../error/errors";
|
|
18
|
+
import { answersRecentAction, chasedOut, mayNudge } from "../nudge/cooldown";
|
|
19
|
+
import { type EnqueueNudge, sendNudge } from "../nudge/send";
|
|
20
|
+
import { buildSnapshot } from "../projection/build";
|
|
21
|
+
import { type CohortReading, countSnapshots, newlyDark, readCohort, requireCohort, snapshotOn } from "../roster/read";
|
|
22
|
+
import { markUnreachable, recordNudge, type WriteDeps } from "../roster/write";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The daily pass — advance what has changed, chase who needs chasing, and record the day's position.
|
|
26
|
+
*
|
|
27
|
+
* **Why a Workflow rather than a scheduled Worker handler.** A Worker invocation is wall-clock bounded;
|
|
28
|
+
* a Workflow step is not. A project running several cohorts of a hundred testers each resolves activity
|
|
29
|
+
* against auth, scores every member, and enqueues nudges — and the pass that matters most is the one on
|
|
30
|
+
* the busiest project. Per-cohort steps also mean a cohort whose activity read fails is retried on its
|
|
31
|
+
* own rather than taking the rest of the day's cohorts down with it.
|
|
32
|
+
*
|
|
33
|
+
* **The snapshot is written last, deliberately.** State transitions and nudges happen first, so the row
|
|
34
|
+
* records the day's *settled* position rather than a mid-pass one — a snapshot claiming twelve opted-in
|
|
35
|
+
* testers while the pass was still about to record the thirteenth would put a wrong point on a chart
|
|
36
|
+
* that nothing later corrects.
|
|
37
|
+
*
|
|
38
|
+
* **Everything here is idempotent, because a Workflow step can re-run.** The snapshot upserts on
|
|
39
|
+
* `(cohortId, snapshotOn)`, `recordAccepted` is a no-op for anyone past `invited`, and the nudge
|
|
40
|
+
* cooldown makes a replay silent rather than duplicative. A retried pass changes nothing it has already
|
|
41
|
+
* done.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/** Milliseconds in one day. */
|
|
45
|
+
const MS_PER_DAY = 86_400_000;
|
|
46
|
+
|
|
47
|
+
/** How long after an invitation to start chasing an unconfirmed tester. */
|
|
48
|
+
const CONFIRM_NUDGE_AFTER_DAYS = 2;
|
|
49
|
+
|
|
50
|
+
/** How many days of silence before an opted-in tester is worth nudging about it. */
|
|
51
|
+
const INACTIVE_NUDGE_AFTER_DAYS = 5;
|
|
52
|
+
|
|
53
|
+
/** How many days from the end of the window the closing nudge goes out. */
|
|
54
|
+
const CLOSING_NUDGE_WITHIN_DAYS = 3;
|
|
55
|
+
|
|
56
|
+
/** What one pass needs. Every dependency injected, so the pass is testable without a Worker. */
|
|
57
|
+
export interface DailyPassDeps {
|
|
58
|
+
readonly db: TestersDatabase;
|
|
59
|
+
readonly d1: D1Database;
|
|
60
|
+
readonly config: TestersConfig;
|
|
61
|
+
readonly now: Date;
|
|
62
|
+
readonly newId: () => string;
|
|
63
|
+
/**
|
|
64
|
+
* Where the pass reports what it could not do.
|
|
65
|
+
*
|
|
66
|
+
* Required rather than defaulted, because silence is this pass's worst failure mode and a default
|
|
67
|
+
* picks it. The Workflow hands over the run's logger, so every line carries the instance an operator
|
|
68
|
+
* searches by; the CLI hands over the process logger, so the same line reaches the terminal.
|
|
69
|
+
*/
|
|
70
|
+
readonly log: Logger;
|
|
71
|
+
/** The email enqueue seam. Absent means state still advances and the snapshot is still written. */
|
|
72
|
+
readonly enqueue: EnqueueNudge | undefined;
|
|
73
|
+
/**
|
|
74
|
+
* The global email-suppression database, for reconciling deliverability.
|
|
75
|
+
*
|
|
76
|
+
* A separate binding because suppression is global rather than per environment — an unsubscribe in
|
|
77
|
+
* prod must stop staging too. Absent means the flag is left alone rather than guessed at.
|
|
78
|
+
*/
|
|
79
|
+
readonly suppressionD1: D1Database | undefined;
|
|
80
|
+
/**
|
|
81
|
+
* The link a nudge of each kind should carry, or undefined to send no linked nudges.
|
|
82
|
+
*
|
|
83
|
+
* Takes the kind because the two linked kinds point at different routes: `confirm` asks whether they
|
|
84
|
+
* will test, `store` sends them on once the developer has added them to the tester list.
|
|
85
|
+
*/
|
|
86
|
+
readonly linkFor: ((kind: NudgeKind, member: TestersMember) => string | undefined) | undefined;
|
|
87
|
+
/** The tester's own way out, carried on every nudge this pass sends. */
|
|
88
|
+
readonly optOutLinkFor: ((member: TestersMember) => string) | undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** What one cohort's pass did. Returned so the Workflow can log it and a test can assert it. */
|
|
92
|
+
export interface CohortPassResult {
|
|
93
|
+
readonly cohortId: string;
|
|
94
|
+
readonly snapshotOn: string;
|
|
95
|
+
readonly nudged: Record<NudgeKind, number>;
|
|
96
|
+
readonly estimatedOptedInCount: number;
|
|
97
|
+
readonly estimatedHeldDays: number;
|
|
98
|
+
readonly trendDirection: string;
|
|
99
|
+
readonly pruned: number;
|
|
100
|
+
/**
|
|
101
|
+
* Why nothing was sent, when nothing was sent for a reason worth reporting.
|
|
102
|
+
*
|
|
103
|
+
* `undefined` means the pass nudged normally (which may still be nobody, if nobody was due).
|
|
104
|
+
*/
|
|
105
|
+
readonly nudgesSkipped?: "no_base_url";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Correct yesterday's row from the event log, now that yesterday has actually ended.
|
|
110
|
+
*
|
|
111
|
+
* The pass runs at `snapshotHourUtc` — 05:00 by default — and writes a row keyed to the day it is five
|
|
112
|
+
* hours into. Every state-moving event in the remaining nineteen hours was therefore missing from that
|
|
113
|
+
* row, and nothing ever went back for it: a cohort reaching its twelfth opt-in at 10:00 was recorded
|
|
114
|
+
* that day as below target with no window start, while the *next* day's row — replayed from the same
|
|
115
|
+
* events — said the at-target run had begun the day before and already held two days. The stored
|
|
116
|
+
* series contradicted the replay it claims to record, and every post-05:00 opt-in plotted a day late,
|
|
117
|
+
* permanently.
|
|
118
|
+
*
|
|
119
|
+
* Only the opt-in clock is corrected, and the split is the one the package already states: the clock
|
|
120
|
+
* can always be replayed from events, the activity figures cannot — sessions rotate and expire, so a
|
|
121
|
+
* tester who was quiet yesterday leaves nothing behind today to prove it. So the estimated columns are
|
|
122
|
+
* rewritten from a replay of the finished day and the observed columns are left exactly as sampled,
|
|
123
|
+
* which is precisely what `backfilled` exists to tell a chart: render the point dashed, the opt-in
|
|
124
|
+
* figures are exact and the activity figures are a floor.
|
|
125
|
+
*
|
|
126
|
+
* Yesterday only. In steady state that is every day, corrected once, the morning after — bounded work
|
|
127
|
+
* and a guarantee that is easy to state. A day nobody ran a pass for has no row to correct, and
|
|
128
|
+
* inventing one would fabricate an activity reading nobody took.
|
|
129
|
+
*/
|
|
130
|
+
async function reconcilePreviousDay(
|
|
131
|
+
deps: DailyPassDeps,
|
|
132
|
+
cohortId: string,
|
|
133
|
+
reading: CohortReading,
|
|
134
|
+
today: DayKey,
|
|
135
|
+
): Promise<void> {
|
|
136
|
+
const day = addDays(today, -1);
|
|
137
|
+
const existing = await snapshotOn(deps.db, cohortId, day);
|
|
138
|
+
if (!existing) return;
|
|
139
|
+
|
|
140
|
+
// Replayed as of the end of that day, so the reading covers all of it rather than the five hours the
|
|
141
|
+
// original pass saw.
|
|
142
|
+
const settled = readClock(
|
|
143
|
+
{
|
|
144
|
+
createdAt: reading.cohort.createdAt,
|
|
145
|
+
targetSize: reading.cohort.targetSize,
|
|
146
|
+
windowDays: reading.cohort.windowDays,
|
|
147
|
+
resetPolicy: reading.cohort.resetPolicy,
|
|
148
|
+
},
|
|
149
|
+
reading.events,
|
|
150
|
+
endOfDay(day),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const corrected: TestersCohortSnapshot = {
|
|
154
|
+
...existing,
|
|
155
|
+
estimatedOptedInCount: settled.estimatedOptedInCount,
|
|
156
|
+
meetsTarget: settled.meetsTarget,
|
|
157
|
+
headroom: settled.headroom,
|
|
158
|
+
estimatedHeldDays: settled.estimatedHeldDays,
|
|
159
|
+
estimatedWindowStartOn: settled.estimatedWindowStartOn,
|
|
160
|
+
estimatedDaysRemaining: settled.estimatedDaysRemaining,
|
|
161
|
+
resetCount: settled.resetCount,
|
|
162
|
+
resetToday: settled.resetToday,
|
|
163
|
+
// The opt-in half was rewritten from a replay; the observed half was not and cannot be.
|
|
164
|
+
backfilled: true,
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// Nothing to say if the day was already right, which is the common case — the pass usually runs on a
|
|
168
|
+
// day whose events all landed before 05:00 the following morning.
|
|
169
|
+
if (
|
|
170
|
+
corrected.estimatedOptedInCount === existing.estimatedOptedInCount &&
|
|
171
|
+
corrected.estimatedHeldDays === existing.estimatedHeldDays &&
|
|
172
|
+
corrected.estimatedWindowStartOn === existing.estimatedWindowStartOn &&
|
|
173
|
+
corrected.resetToday === existing.resetToday
|
|
174
|
+
) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
await upsertSnapshot(deps.db, corrected);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Add this pass's nudge counts to whatever the day had already recorded. */
|
|
182
|
+
function addTallies(existing: NudgeTally | undefined, added: Record<NudgeKind, number>): Record<NudgeKind, number> {
|
|
183
|
+
if (!existing) return added;
|
|
184
|
+
return Object.fromEntries(NudgeKind.options.map((kind) => [kind, (existing[kind] ?? 0) + added[kind]])) as Record<
|
|
185
|
+
NudgeKind,
|
|
186
|
+
number
|
|
187
|
+
>;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Which nudge, if any, this tester is due today.
|
|
192
|
+
*
|
|
193
|
+
* Returns at most one. A tester who is both unconfirmed and quiet gets the confirmation nudge, because
|
|
194
|
+
* confirming is the thing that actually moves the count and asking for two things at once gets neither.
|
|
195
|
+
*/
|
|
196
|
+
export function dueNudge(
|
|
197
|
+
member: TestersMember,
|
|
198
|
+
context: { now: Date; daysRemaining: number; lastSeenAt: Date | null; storeReady: boolean },
|
|
199
|
+
): NudgeKind | null {
|
|
200
|
+
// Step one: will you help test? Sent promptly the first time — a tester added from the CLI has been
|
|
201
|
+
// put on the roster but not actually told, and making them wait two days for the email is the
|
|
202
|
+
// difference between a capability that works without a dashboard and one that only appears to.
|
|
203
|
+
// Stop chasing someone who has not answered three times. "Answer" means accepting the invitation or
|
|
204
|
+
// confirming the opt-in — the two replies this capability asks for — and either clears the counter.
|
|
205
|
+
// Opening the app does not, deliberately: activity is a separate signal with its own penalties, and
|
|
206
|
+
// a tester who installs but never replies is exactly who the cap is for. The consequence worth
|
|
207
|
+
// knowing is that an already-opted-in tester has no reply left to give, so three unanswered
|
|
208
|
+
// `inactive` or `closing` nudges stop their chasing for the life of the cohort. That is the intended
|
|
209
|
+
// trade — they stay on the roster and stay visible, they simply stop being mailed.
|
|
210
|
+
if (chasedOut(member)) return null;
|
|
211
|
+
|
|
212
|
+
if (member.state === "invited") {
|
|
213
|
+
if (member.nudgeCount === 0) return "confirm";
|
|
214
|
+
return daysSince(member.lastInvitedAt, context.now) >= CONFIRM_NUDGE_AFTER_DAYS ? "confirm" : null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Step two: they said yes, and the developer has supplied the store's opt-in link — which is their
|
|
218
|
+
// signal that this address is now on the tester list. Without that link the store page would answer
|
|
219
|
+
// `App not available`, so we hold the email rather than send someone to a dead end.
|
|
220
|
+
if (member.state === "accepted") {
|
|
221
|
+
if (!context.storeReady) return null;
|
|
222
|
+
return "store";
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (member.state !== "opted_in") return null;
|
|
226
|
+
|
|
227
|
+
// The window is nearly up and they are still in. One message, so the last thing they hear is not a
|
|
228
|
+
// complaint about their engagement.
|
|
229
|
+
if (context.daysRemaining > 0 && context.daysRemaining <= CLOSING_NUDGE_WITHIN_DAYS) return "closing";
|
|
230
|
+
|
|
231
|
+
// Only an observed tester can be nudged about inactivity. Nudging someone who simply never signs in —
|
|
232
|
+
// because the app never asks them to — would be chasing them for something they did not do wrong.
|
|
233
|
+
if (context.lastSeenAt && daysSince(context.lastSeenAt, context.now) >= INACTIVE_NUDGE_AFTER_DAYS) {
|
|
234
|
+
return "inactive";
|
|
235
|
+
}
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Which of these addresses the email capability is suppressing, or `undefined` when it cannot be asked.
|
|
241
|
+
*
|
|
242
|
+
* A guarded dynamic import over an optional binding: a project can run cohorts without the suppression
|
|
243
|
+
* database bound, and the honest answer then is to leave the flag alone rather than infer a bounce from
|
|
244
|
+
* its own absence.
|
|
245
|
+
*/
|
|
246
|
+
async function readSuppressed(deps: DailyPassDeps, emails: readonly string[]): Promise<Set<string> | undefined> {
|
|
247
|
+
if (!deps.suppressionD1 || emails.length === 0) return undefined;
|
|
248
|
+
try {
|
|
249
|
+
const { emailSuppressionDatabase } = await import("@pithy-sh/email/src/data/tables");
|
|
250
|
+
const db = emailSuppressionDatabase(deps.suppressionD1);
|
|
251
|
+
const found = new Set<string>();
|
|
252
|
+
for (const chunk of chunkByBoundParameters([...new Set(emails)], 0)) {
|
|
253
|
+
const rows = await db
|
|
254
|
+
.selectFrom("pithyEmailSuppressions")
|
|
255
|
+
.select(["email", "expiresAt"])
|
|
256
|
+
.where("email", "in", chunk)
|
|
257
|
+
.execute();
|
|
258
|
+
for (const row of rows) {
|
|
259
|
+
// The reason is deliberately not consulted, unlike on the send path: `testerNudge` is elective
|
|
260
|
+
// mail, so every reason blocks it — a bounce, a complaint and an opt-out all mean "stop chasing
|
|
261
|
+
// this person". A cohort is something you can leave.
|
|
262
|
+
//
|
|
263
|
+
// A row whose `expiresAt` has passed is a lapsed temporary suppression, not a live one — the
|
|
264
|
+
// same rule `@pithy-sh/email`'s own `blockingSuppression` applies. Treating every row as suppressing
|
|
265
|
+
// would strand a tester for the whole cohort because their mailbox was full one afternoon.
|
|
266
|
+
const expiresAt =
|
|
267
|
+
row.expiresAt === null || row.expiresAt === undefined ? null : new Date(Number(row.expiresAt));
|
|
268
|
+
if (expiresAt === null || expiresAt > deps.now) found.add(normalizeAddress(String(row.email)));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return found;
|
|
272
|
+
} catch (error) {
|
|
273
|
+
// `warn`, not `error`: the pass carries on and still writes its day. But it must not go quiet —
|
|
274
|
+
// the same catch covers a transient D1 failure, and a roster whose bounces stopped being
|
|
275
|
+
// reconciled looks exactly like a roster with no bounces.
|
|
276
|
+
deps.log.warn("suppression list unreadable, leaving deliverability flags alone", {
|
|
277
|
+
addresses: emails.length,
|
|
278
|
+
reason: messageOf(error),
|
|
279
|
+
});
|
|
280
|
+
return undefined;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Run the pass for one cohort. */
|
|
285
|
+
export async function runCohortPass(deps: DailyPassDeps, cohortId: string): Promise<CohortPassResult> {
|
|
286
|
+
const write: WriteDeps = { db: deps.db, now: deps.now, newId: deps.newId };
|
|
287
|
+
const cohort = await requireCohort(deps.db, cohortId);
|
|
288
|
+
// Checked here rather than only in `openCohortIds`, because that filter is not the only caller.
|
|
289
|
+
// `pithy testers run --cohort <closed>` reaches this function directly — the flag documented as
|
|
290
|
+
// "run one cohort only" was the one path that ignored closure — and it mailed a finished cohort's
|
|
291
|
+
// testers and wrote them a fresh snapshot. `close` prints "Nothing further is sent."
|
|
292
|
+
if (cohort.closedAt !== null) {
|
|
293
|
+
throw new TestersCohortClosedError({
|
|
294
|
+
detail: `cohort ${cohort.id} was closed at ${cohort.closedAt.toISOString()}`,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const reading = await readCohort(deps.db, deps.d1, cohort, deps.config, deps.now, deps.log);
|
|
299
|
+
|
|
300
|
+
// 1. Reconcile deliverability against the suppression list — the only place that fact actually lives.
|
|
301
|
+
// Comparing `activity.observability === "unreachable"` here would compare the flag with itself:
|
|
302
|
+
// `readCohort` builds that value FROM `member.unreachable`, so the condition could never fire and
|
|
303
|
+
// no bounce was ever recorded.
|
|
304
|
+
const suppressed = await readSuppressed(
|
|
305
|
+
deps,
|
|
306
|
+
reading.readings.map((entry) => entry.member.email),
|
|
307
|
+
);
|
|
308
|
+
let bounced = 0;
|
|
309
|
+
if (suppressed) {
|
|
310
|
+
for (const entry of reading.readings) {
|
|
311
|
+
const isSuppressed = suppressed.has(entry.member.email);
|
|
312
|
+
if (isSuppressed !== entry.member.unreachable) {
|
|
313
|
+
await markUnreachable(write, entry.member.id, isSuppressed);
|
|
314
|
+
if (isSuppressed) bounced++;
|
|
315
|
+
// Reflected in the reading too, so the nudge loop and the snapshot below both see what this
|
|
316
|
+
// pass just learned. Without it, an address discovered to be bouncing this morning is still
|
|
317
|
+
// mailed this morning, and the snapshot records it as reachable.
|
|
318
|
+
(entry as { member: TestersMember }).member = { ...entry.member, unreachable: isSuppressed };
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// 2. Chase whoever is due, subject to the cooldown. The cooldown is checked here rather than trusted
|
|
324
|
+
// to the caller, because this is a caller — a cron that ran twice would otherwise mail twice.
|
|
325
|
+
const nudged: Record<NudgeKind, number> = { confirm: 0, store: 0, inactive: 0, closing: 0 };
|
|
326
|
+
|
|
327
|
+
// No `baseUrl` means no link can be built, and the link builders throw rather than returning
|
|
328
|
+
// undefined — so without this the first due nudge threw `TestersNotConfiguredError` out of the loop,
|
|
329
|
+
// past the step boundary, and the snapshot below never ran. Under `runDailyPass` the outer loop
|
|
330
|
+
// aborted too, so every later cohort lost its day as well. The chart the capability exists to draw
|
|
331
|
+
// gained a permanent gap whose only symptom was a failed Workflow instance nobody watches.
|
|
332
|
+
//
|
|
333
|
+
// Skipping is the right answer rather than sending anyway: every nudge must carry the tester's own
|
|
334
|
+
// way out, and that link needs the same `baseUrl`. Recording the day still happens, because the
|
|
335
|
+
// day's position is true whether or not any mail went out.
|
|
336
|
+
const canLink = deps.config.baseUrl !== undefined;
|
|
337
|
+
if (deps.enqueue && canLink) {
|
|
338
|
+
for (const entry of reading.readings) {
|
|
339
|
+
const kind = dueNudge(entry.member, {
|
|
340
|
+
now: deps.now,
|
|
341
|
+
daysRemaining: reading.clock.estimatedDaysRemaining,
|
|
342
|
+
lastSeenAt: entry.activity.lastAuthenticatedAt,
|
|
343
|
+
// The same test the route applies, not merely "a value is present". These two disagreeing is
|
|
344
|
+
// the worst failure this capability has: the pass reads a non-null junk URL as ready and mails
|
|
345
|
+
// the store email, the tester follows it, `confirmOptIn` moves them to `opted_in` — so the
|
|
346
|
+
// estimate climbs — and only then does the route's host check fail and drop them on the
|
|
347
|
+
// "the developer will be in touch" page. Nobody is enrolled with Google, and the number says
|
|
348
|
+
// otherwise.
|
|
349
|
+
storeReady: cohort.storeOptInUrl !== null && isStoreOptInUrl(cohort.storeOptInUrl),
|
|
350
|
+
});
|
|
351
|
+
if (!kind) continue;
|
|
352
|
+
if (entry.member.unreachable) continue;
|
|
353
|
+
// The cooldown stops repeated chasing; it must not delay a reply. A tester who just agreed and is
|
|
354
|
+
// waiting for the link is waiting on us, and holding it for three days is how a cohort loses the
|
|
355
|
+
// people who were most willing.
|
|
356
|
+
const isReply = kind === "store" && answersRecentAction(entry.member);
|
|
357
|
+
if (!isReply && !mayNudge(entry.member, deps.config.nudges.cooldownHours, deps.now)) continue;
|
|
358
|
+
|
|
359
|
+
const ctaUrl = deps.linkFor?.(kind, entry.member);
|
|
360
|
+
// A linked nudge with no link is just nagging. Skip it rather than send a message asking someone
|
|
361
|
+
// to click something that is not there.
|
|
362
|
+
if ((kind === "confirm" || kind === "store") && !ctaUrl) continue;
|
|
363
|
+
|
|
364
|
+
const sent = await sendNudge(deps.enqueue, {
|
|
365
|
+
member: entry.member,
|
|
366
|
+
kind,
|
|
367
|
+
supplied: undefined,
|
|
368
|
+
ctaUrl,
|
|
369
|
+
optOutUrl: deps.optOutLinkFor?.(entry.member),
|
|
370
|
+
});
|
|
371
|
+
await recordNudge(write, {
|
|
372
|
+
memberId: entry.member.id,
|
|
373
|
+
nudgeKind: kind,
|
|
374
|
+
jobId: sent.jobId,
|
|
375
|
+
copySource: "default",
|
|
376
|
+
});
|
|
377
|
+
nudged[kind]++;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// 3. Record the day. Last, so it describes the settled position.
|
|
382
|
+
const today = dayKey(deps.now);
|
|
383
|
+
const yesterday = await snapshotOn(deps.db, cohortId, addDays(today, -1));
|
|
384
|
+
const weekAgo = await snapshotOn(deps.db, cohortId, addDays(today, -7));
|
|
385
|
+
const existing = await snapshotOn(deps.db, cohortId, today);
|
|
386
|
+
|
|
387
|
+
const snapshot = buildSnapshot({
|
|
388
|
+
cohort: reading.cohort,
|
|
389
|
+
config: deps.config,
|
|
390
|
+
clock: reading.clock,
|
|
391
|
+
readings: reading.readings,
|
|
392
|
+
yesterday: yesterday
|
|
393
|
+
? {
|
|
394
|
+
estimatedOptedInCount: yesterday.estimatedOptedInCount,
|
|
395
|
+
activeCount: yesterday.activeCount,
|
|
396
|
+
successProbability: yesterday.successProbability,
|
|
397
|
+
}
|
|
398
|
+
: null,
|
|
399
|
+
weekAgo: weekAgo
|
|
400
|
+
? {
|
|
401
|
+
estimatedOptedInCount: weekAgo.estimatedOptedInCount,
|
|
402
|
+
activeCount: weekAgo.activeCount,
|
|
403
|
+
successProbability: weekAgo.successProbability,
|
|
404
|
+
}
|
|
405
|
+
: null,
|
|
406
|
+
yesterdayMetTarget: yesterday?.meetsTarget ?? null,
|
|
407
|
+
snapshotCount: (await countSnapshots(deps.db, cohortId)) + (existing ? 0 : 1),
|
|
408
|
+
// Added to what the day already recorded, not substituted for it. These two are the only columns
|
|
409
|
+
// on the row derived from what happened *during* this invocation rather than from durable state —
|
|
410
|
+
// every other one is recomputed from events, member rows and activity, so a re-run re-derives it
|
|
411
|
+
// identically. `nudged` and `bounced` on a re-run of a covered day are correctly zero (the cooldown
|
|
412
|
+
// suppresses the sends, the suppression flags already agree), and the upsert takes every column
|
|
413
|
+
// from `excluded` — so an operator running `pithy testers run` in the afternoon overwrote the
|
|
414
|
+
// morning's record of three sends and a bounce with zeros. The mail had gone out. The evidence of
|
|
415
|
+
// it had not survived, and `snapshot.ts` is explicit that what is not written down on the day is
|
|
416
|
+
// gone for good.
|
|
417
|
+
nudgesSent: addTallies(existing?.nudgesSent, nudged),
|
|
418
|
+
bouncedCount: (existing?.bouncedCount ?? 0) + bounced,
|
|
419
|
+
newlyDarkCount: newlyDark(reading.readings, deps.now),
|
|
420
|
+
now: deps.now,
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
await upsertSnapshot(deps.db, snapshot);
|
|
424
|
+
await reconcilePreviousDay(deps, cohortId, reading, today);
|
|
425
|
+
const pruned = await pruneSnapshots(deps.db, cohortId, deps.config.snapshotRetentionDays, deps.now);
|
|
426
|
+
|
|
427
|
+
return {
|
|
428
|
+
cohortId,
|
|
429
|
+
snapshotOn: snapshot.snapshotOn,
|
|
430
|
+
nudged,
|
|
431
|
+
estimatedOptedInCount: snapshot.estimatedOptedInCount,
|
|
432
|
+
estimatedHeldDays: snapshot.estimatedHeldDays,
|
|
433
|
+
trendDirection: snapshot.trendDirection,
|
|
434
|
+
pruned,
|
|
435
|
+
// Reported rather than silent. A pass that mailed nobody because it could not build a link looks
|
|
436
|
+
// identical to a pass where nobody was due, and those are very different states to be in on day 9.
|
|
437
|
+
...(deps.enqueue && !canLink ? { nudgesSkipped: "no_base_url" as const } : {}),
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Write one day's snapshot, replacing any existing row for that day.
|
|
443
|
+
*
|
|
444
|
+
* The upsert is what makes a re-run safe: a retried Workflow step, a manual backfill, and two crons
|
|
445
|
+
* firing a second apart all rewrite the day rather than appending a second version of it. A duplicated
|
|
446
|
+
* day would put two points at one x-coordinate, and every delta computed from it afterwards would be
|
|
447
|
+
* wrong in a way nothing later notices.
|
|
448
|
+
*/
|
|
449
|
+
export async function upsertSnapshot(db: TestersDatabase, snapshot: TestersCohortSnapshot): Promise<void> {
|
|
450
|
+
const row = TestersCohortSnapshot.encode(snapshot);
|
|
451
|
+
const { id: _generated, ...insertable } = row;
|
|
452
|
+
|
|
453
|
+
// The update half references `excluded` rather than re-binding every column, and that is a
|
|
454
|
+
// correctness fix rather than a tidiness one. A snapshot has fifty-eight columns; binding them once
|
|
455
|
+
// for the insert and again for the update is a hundred and sixteen bound parameters, and **D1 rejects
|
|
456
|
+
// any statement over one hundred**. Written the obvious way, the very first daily pass fails with
|
|
457
|
+
// `too many SQL variables` — in a cron, at 05:00, where nobody is watching. `excluded` binds nothing.
|
|
458
|
+
const updateFromExcluded = (eb: ExpressionBuilder<Record<string, Record<string, unknown>>, string>) =>
|
|
459
|
+
Object.fromEntries(Object.keys(insertable).map((column) => [column, eb.ref(`excluded.${column}`)]));
|
|
460
|
+
|
|
461
|
+
await db
|
|
462
|
+
.insertInto(TESTERS_SNAPSHOTS_TABLE)
|
|
463
|
+
// biome-ignore lint/suspicious/noExplicitAny: the row is the schema's z.input side; Kysely's insert type derives from it.
|
|
464
|
+
.values(insertable as any)
|
|
465
|
+
.onConflict((oc) =>
|
|
466
|
+
oc.columns(["cohortId", "snapshotOn"]).doUpdateSet(
|
|
467
|
+
// biome-ignore lint/suspicious/noExplicitAny: the column map is built from the same z.input side.
|
|
468
|
+
updateFromExcluded as any,
|
|
469
|
+
),
|
|
470
|
+
)
|
|
471
|
+
.execute();
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Drop snapshots older than the retention window. Returns how many went. */
|
|
475
|
+
export async function pruneSnapshots(
|
|
476
|
+
db: TestersDatabase,
|
|
477
|
+
cohortId: string,
|
|
478
|
+
retentionDays: number,
|
|
479
|
+
now: Date,
|
|
480
|
+
): Promise<number> {
|
|
481
|
+
const cutoff = dayKey(new Date(now.getTime() - retentionDays * MS_PER_DAY));
|
|
482
|
+
const result = await db
|
|
483
|
+
.deleteFrom(TESTERS_SNAPSHOTS_TABLE)
|
|
484
|
+
.where("cohortId", "=", cohortId)
|
|
485
|
+
.where("snapshotOn", "<", cutoff)
|
|
486
|
+
.executeTakeFirst();
|
|
487
|
+
return Number(result?.numDeletedRows ?? 0);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Run the pass for every open cohort. A closed cohort keeps its history and accrues nothing new. */
|
|
491
|
+
export async function openCohortIds(db: DailyPassDeps["db"]): Promise<string[]> {
|
|
492
|
+
const rows = await db.selectFrom(TESTERS_COHORTS_TABLE).select(["id"]).where("closedAt", "is", null).execute();
|
|
493
|
+
return rows.map((row) => String(row.id));
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Run the pass for every open cohort.
|
|
498
|
+
*
|
|
499
|
+
* Kept for the CLI and for tests, where one process runs the lot. **The Workflow does not use this** —
|
|
500
|
+
* it enumerates with {@link openCohortIds} and gives each cohort its own `step.do`, so a cohort that
|
|
501
|
+
* throws is retried on its own rather than taking the rest of the day's cohorts down with it. That
|
|
502
|
+
* distinction is the whole reason this function is not what the worker calls.
|
|
503
|
+
*
|
|
504
|
+
* Here, a throw still aborts the remaining cohorts. That is the right shape for a foreground command
|
|
505
|
+
* where somebody is reading the error.
|
|
506
|
+
*/
|
|
507
|
+
export async function runDailyPass(deps: DailyPassDeps): Promise<CohortPassResult[]> {
|
|
508
|
+
const results: CohortPassResult[] = [];
|
|
509
|
+
for (const cohortId of await openCohortIds(deps.db)) {
|
|
510
|
+
results.push(await runCohortPass(deps, cohortId));
|
|
511
|
+
}
|
|
512
|
+
return results;
|
|
513
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { type CohortPassResult, type DailyPassDeps, openCohortIds, runCohortPass } from "./daily";
|
|
5
|
+
import { logCohortFailure, logPassComplete } from "./report";
|
|
6
|
+
import type { TestersDailyParams } from "./specs";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The durable daily pass — the body `TestersDailyWorkflow.run` hands its step runner to.
|
|
10
|
+
*
|
|
11
|
+
* **Why it is here and not in the Workflow class.** `worker.ts` imports `cloudflare:workers`, which
|
|
12
|
+
* resolves in workerd and nowhere else, so anything inside it can only be exercised by deploying it.
|
|
13
|
+
* The properties that matter about this body are all properties of a *resume* — a Workflow does not
|
|
14
|
+
* resume inside the step it died in, it re-executes this function from the top and serves every
|
|
15
|
+
* completed step from the journal — and the only way to know a resume behaves is to drive one. So the
|
|
16
|
+
* step runner is structural and injected, exactly as `reconcilePayments` and `runAtRestKeyRotation`
|
|
17
|
+
* take theirs, and `worker.ts` is the shell that supplies the real one.
|
|
18
|
+
*
|
|
19
|
+
* **One step per cohort.** A cohort whose activity read fails is retried on its own rather than taking
|
|
20
|
+
* the rest of the day's cohorts down with it, and a retried step re-runs an idempotent pass.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** The durable step runner, structurally. Injected, so a test can drive an interrupt and a resume. */
|
|
24
|
+
export interface DailyPassStep {
|
|
25
|
+
/** Run a named step, or return its journalled result if this instance already completed it. */
|
|
26
|
+
do<T>(name: string, fn: () => Promise<T>): Promise<T>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What a durable pass needs: everything one cohort's pass needs, minus the clock it journals itself.
|
|
31
|
+
*
|
|
32
|
+
* `now` is deliberately absent. It is the one value a caller must not supply here, because the whole
|
|
33
|
+
* point of this function is that the instant is read once and read back from the journal on a resume.
|
|
34
|
+
*/
|
|
35
|
+
export interface DurableDailyPassDeps extends Omit<DailyPassDeps, "now"> {
|
|
36
|
+
/**
|
|
37
|
+
* The clock, for a test that wants a fixed one.
|
|
38
|
+
*
|
|
39
|
+
* A thunk rather than a `Date`, and read **inside** the `pass-instant` step: a `Date` here would be
|
|
40
|
+
* a value the driver body already holds, which is the defect this function exists not to have.
|
|
41
|
+
*/
|
|
42
|
+
readonly clock?: () => Date;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Run one durable daily pass: journal the instant it began, then one step per cohort. */
|
|
46
|
+
export async function runDurableDailyPass(
|
|
47
|
+
deps: DurableDailyPassDeps,
|
|
48
|
+
step: DailyPassStep,
|
|
49
|
+
params: TestersDailyParams,
|
|
50
|
+
): Promise<CohortPassResult[]> {
|
|
51
|
+
/**
|
|
52
|
+
* The pass instant, journalled (pithy-sh/pithy#328).
|
|
53
|
+
*
|
|
54
|
+
* A Workflow re-executes this body from the top on a resume, so a clock read beside this line answers
|
|
55
|
+
* differently on every attempt — and this clock decides the **day key** every snapshot is filed under.
|
|
56
|
+
* A pass that began at 23:58 and resumed at 00:05 therefore filed its remaining cohorts under the next
|
|
57
|
+
* day, splitting one run across two rows of a series that nothing later corrects. A straddling pass
|
|
58
|
+
* belongs to the day it began: that is the day it sampled activity on, and the day whose position it
|
|
59
|
+
* is recording.
|
|
60
|
+
*
|
|
61
|
+
* **The other clock is deliberately not this one.** Nudges are enqueued through `buildNudgeEnqueue`,
|
|
62
|
+
* which reads its own clock per nudge, because the instant on an email job is `createdAt` and the email
|
|
63
|
+
* scheduler re-drives a `pending` job older than `graceMs` on the assumption its dispatch died. A nudge
|
|
64
|
+
* stamped with an instant this pass read an hour ago would be born already past that cutoff and raced
|
|
65
|
+
* by a second send Workflow. Day key stable, enqueue fresh — they are two questions, and one variable
|
|
66
|
+
* answering both is what this issue and pithy-sh/pithy#327 are each half of.
|
|
67
|
+
*
|
|
68
|
+
* Epoch milliseconds rather than a `Date`, because a journal round-trips JSON: a `Date` would come back
|
|
69
|
+
* a string on the resume and an object on the first pass.
|
|
70
|
+
*/
|
|
71
|
+
const startedAtMs: number = await step.do("pass-instant", async () => (deps.clock ?? (() => new Date()))().getTime());
|
|
72
|
+
const passDeps: DailyPassDeps = { ...deps, now: new Date(startedAtMs) };
|
|
73
|
+
|
|
74
|
+
if (params.cohortId) {
|
|
75
|
+
const result = await step.do(`cohort-${params.cohortId}`, () => runCohortPass(passDeps, params.cohortId as string));
|
|
76
|
+
logPassComplete(deps.log, [result]);
|
|
77
|
+
return [result];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Enumerated in its own step, then one step per cohort — which is what the comment above has always
|
|
81
|
+
// claimed and what every other Workflow in this repo does. A single `all-cohorts` step wrapping the
|
|
82
|
+
// loop meant a deterministic failure on one cohort (a corrupt member row, a cohort deleted between
|
|
83
|
+
// the list and the read) denied every cohort after it its snapshot and its nudges, on every retry
|
|
84
|
+
// attempt, until retries ran out. The darkness histogram cannot be recomputed after the fact, so
|
|
85
|
+
// those days were gone for good.
|
|
86
|
+
const cohortIds: string[] = await step.do("list-cohorts", async () => await openCohortIds(deps.db));
|
|
87
|
+
|
|
88
|
+
const results: CohortPassResult[] = [];
|
|
89
|
+
for (const cohortId of cohortIds) {
|
|
90
|
+
// Contained per cohort. A cohort that keeps failing loses its own day rather than everyone's, and
|
|
91
|
+
// the failure is still visible in the Workflow instance rather than swallowed.
|
|
92
|
+
try {
|
|
93
|
+
results.push(await step.do(`cohort-${cohortId}`, () => runCohortPass(passDeps, cohortId)));
|
|
94
|
+
} catch (error) {
|
|
95
|
+
logCohortFailure(deps.log, cohortId, error);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
logPassComplete(deps.log, results);
|
|
99
|
+
return results;
|
|
100
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { messageOf, PithyError } from "@pithy-sh/core/src/error/pithyError";
|
|
5
|
+
import type { Logger } from "@pithy-sh/core/src/logger/logger";
|
|
6
|
+
import type { CohortPassResult } from "./daily";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What the daily pass says about itself: its tally, and each cohort it could not finish.
|
|
10
|
+
*
|
|
11
|
+
* Both are pure functions of a logger and a value, and both used to live in `worker.ts` beside the
|
|
12
|
+
* Workflow class that calls them. That module imports `cloudflare:workers`, which resolves in workerd
|
|
13
|
+
* and nowhere else, so everything it exported was reachable only from inside the Workers runtime — a
|
|
14
|
+
* Node-side caller taking one of these would have taken the whole runtime module with it and failed
|
|
15
|
+
* with `Could not load pithy.config.ts`, naming the config rather than the import.
|
|
16
|
+
*
|
|
17
|
+
* That is #172 and #180, twice, and neither was noticed until somebody accepted the offer. The shape
|
|
18
|
+
* both were fixed into is this one: a sibling module with no runtime import, which the runtime module
|
|
19
|
+
* imports from. `configEntrypoints.test.ts` states the invariant and holds every runtime module to it.
|
|
20
|
+
*
|
|
21
|
+
* Nothing here needs a binding, a request, or a Workers global, which is why its tests are node tests.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The pass's outcome, as one record. The run's only visible output: a pass whose findings are invisible
|
|
26
|
+
* is a pass nobody can tell has stopped working.
|
|
27
|
+
*
|
|
28
|
+
* Two levels, and the distinction is the whole reason this is not one flat `info`. A cohort carrying
|
|
29
|
+
* `nudgesSkipped` advanced its state and wrote its day but mailed nobody — the pass *looks* healthy and
|
|
30
|
+
* nobody is being chased, which is the one failure mode this capability cannot afford, because silence
|
|
31
|
+
* is also what success looks like. Everything else is routine, and a daily job that reports routine at
|
|
32
|
+
* `warn` teaches an operator to stop reading it.
|
|
33
|
+
*/
|
|
34
|
+
export function logPassComplete(log: Logger, results: readonly CohortPassResult[]): void {
|
|
35
|
+
const skipped = results.filter((result) => result.nudgesSkipped !== undefined).length;
|
|
36
|
+
log[skipped > 0 ? "warn" : "info"]("daily pass complete", { cohorts: results.length, skipped, results });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One cohort's failure, with its payload intact.
|
|
41
|
+
*
|
|
42
|
+
* A `PithyError` goes in the reserved `error` field so the record carries its code, its status and its
|
|
43
|
+
* throw-site `detail` — a log is an internal surface, the inverse of the HTTP codec that strips it.
|
|
44
|
+
* Anything else takes `reason`: a plain `Error`'s `message` and `stack` are non-enumerable, so the
|
|
45
|
+
* reserved field would serialize it to `{}` and lose the only thing it had to say.
|
|
46
|
+
*/
|
|
47
|
+
export function logCohortFailure(log: Logger, cohortId: string, error: unknown): void {
|
|
48
|
+
log.error("cohort pass failed", {
|
|
49
|
+
cohort: cohortId,
|
|
50
|
+
...(error instanceof PithyError ? { error } : { reason: messageOf(error) }),
|
|
51
|
+
});
|
|
52
|
+
}
|