@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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +17 -0
  3. package/docs/store-apis.md +107 -0
  4. package/package.json +62 -0
  5. package/pithy.manifest.json +52 -0
  6. package/src/activity/resolve.ts +273 -0
  7. package/src/audit/actions.ts +56 -0
  8. package/src/capability.ts +128 -0
  9. package/src/clock/days.ts +70 -0
  10. package/src/clock/replay.ts +190 -0
  11. package/src/cloudflare-test.d.ts +13 -0
  12. package/src/config/config.ts +518 -0
  13. package/src/crypto/token.ts +60 -0
  14. package/src/data/cohort.ts +83 -0
  15. package/src/data/enums.ts +134 -0
  16. package/src/data/event.ts +81 -0
  17. package/src/data/member.ts +79 -0
  18. package/src/data/snapshot.ts +280 -0
  19. package/src/data/tables.ts +49 -0
  20. package/src/error/errors.ts +229 -0
  21. package/src/health/score.ts +225 -0
  22. package/src/http/guards.ts +37 -0
  23. package/src/http/pages.ts +66 -0
  24. package/src/http/responses.ts +634 -0
  25. package/src/http/routes.ts +933 -0
  26. package/src/http/schemas.ts +210 -0
  27. package/src/http/scopes.ts +79 -0
  28. package/src/http/view.ts +304 -0
  29. package/src/index.ts +80 -0
  30. package/src/migrations/0001_cohorts.ts +202 -0
  31. package/src/nudge/cooldown.ts +104 -0
  32. package/src/nudge/copy.ts +179 -0
  33. package/src/nudge/enqueueSeam.ts +95 -0
  34. package/src/nudge/send.ts +89 -0
  35. package/src/projection/build.ts +285 -0
  36. package/src/projection/forecast.ts +348 -0
  37. package/src/projection/inputs.ts +63 -0
  38. package/src/projection/poissonBinomial.ts +91 -0
  39. package/src/projection/trend.ts +185 -0
  40. package/src/provision/provisionTesters.ts +109 -0
  41. package/src/provision/resolveTestersConfig.ts +155 -0
  42. package/src/roster/read.ts +227 -0
  43. package/src/roster/write.ts +511 -0
  44. package/src/seeds/example.ts +219 -0
  45. package/src/version.generated.ts +16 -0
  46. package/src/workflows/daily.ts +513 -0
  47. package/src/workflows/pass.ts +100 -0
  48. package/src/workflows/report.ts +52 -0
  49. package/src/workflows/retryPolicy.ts +48 -0
  50. package/src/workflows/specs.ts +73 -0
  51. package/src/workflows/worker.ts +132 -0
  52. 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
+ }