@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,89 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { NudgeKind } from "../data/enums";
5
+ import type { TestersMember } from "../data/member";
6
+ import { ESTIMATE_STATEMENT } from "../http/responses";
7
+ import { type ResolvedCopy, resolveCopy } from "./copy";
8
+
9
+ /**
10
+ * Sending a nudge — the one place words, a recipient, and the adopter's sending domain meet.
11
+ *
12
+ * **Nothing here sends an email.** It enqueues a job through the seam `@pithy-sh/email` exposes, so
13
+ * every nudge inherits the existing path in full: the send Workflow with its retries, the suppression
14
+ * list, bounce handling, and the job history that records exactly what went out and when. A nudge that
15
+ * bypassed that to send inline would be a second delivery path with none of it, and the first hard
16
+ * bounce would keep being retried forever.
17
+ *
18
+ * **The email job is the record of what was sent.** The supplied copy lands in the job's payload, and
19
+ * the `nudged` event records whether the words were ours or a caller's. Between them, "who mailed my
20
+ * users, saying what, on whose authority" is answerable after the fact rather than a matter of trust.
21
+ */
22
+
23
+ /** The enqueue seam, as `@pithy-sh/email` exposes it — bound to the request env by the caller. */
24
+ export type EnqueueNudge = (input: {
25
+ to: string;
26
+ template: string;
27
+ payload: unknown;
28
+ }) => Promise<{ jobId: string; status: string }>;
29
+
30
+ /** What sending one nudge needs. */
31
+ export interface NudgeSendInput {
32
+ readonly member: TestersMember;
33
+ readonly kind: NudgeKind;
34
+ /** Copy supplied by a control-plane caller, if any and if the deployment allows it. */
35
+ readonly supplied: { subject?: string; body?: string } | undefined;
36
+ /** The confirmation link, for a `confirm` nudge. Absent for the others. */
37
+ readonly ctaUrl: string | undefined;
38
+ /**
39
+ * The tester's own way out.
40
+ *
41
+ * Carried on every nudge, not just the marketing-shaped ones. This capability repeatedly asks a
42
+ * person for something over weeks; someone being chased must be able to stop it, and
43
+ * without this the opt-out route we built is unreachable — a door with no handle on the inside.
44
+ */
45
+ readonly optOutUrl: string | undefined;
46
+ }
47
+
48
+ /** What one enqueued nudge produced. */
49
+ export interface NudgeSendResult {
50
+ readonly memberId: string;
51
+ readonly jobId: string;
52
+ readonly copy: ResolvedCopy;
53
+ }
54
+
55
+ /**
56
+ * The closing line on every nudge.
57
+ *
58
+ * The same sentence the API and the CLI carry, in the tester's mail too. It is there because a tester
59
+ * asked to "stay opted in for fourteen days" reasonably assumes whoever is asking can see whether they
60
+ * have — and being straight about that is what stops the developer inheriting an argument they cannot
61
+ * win on day fifteen.
62
+ */
63
+ const FOOTNOTE = ESTIMATE_STATEMENT;
64
+
65
+ /**
66
+ * Enqueue one nudge.
67
+ *
68
+ * The caller has already applied the cooldown; this does not re-check it, because the cooldown is a
69
+ * decision about a *set* of testers — who to mail and who to skip — and making it here would mean
70
+ * discovering a skip after the batch had already been composed.
71
+ */
72
+ export async function sendNudge(enqueue: EnqueueNudge, input: NudgeSendInput): Promise<NudgeSendResult> {
73
+ const copy = resolveCopy(input.kind, input.supplied);
74
+ const result = await enqueue({
75
+ to: input.member.email,
76
+ template: "testerNudge",
77
+ payload: {
78
+ subject: copy.subject,
79
+ heading: copy.heading,
80
+ // An array of plain strings, each rendered HTML-escaped by the template. This is the line that
81
+ // makes caller-supplied copy safe to send over the adopter's own DKIM signature.
82
+ paragraphs: [...copy.paragraphs],
83
+ ...(input.ctaUrl ? { ctaUrl: input.ctaUrl, ctaLabel: copy.ctaLabel ?? "Confirm" } : {}),
84
+ footnote: FOOTNOTE,
85
+ ...(input.optOutUrl ? { optOutUrl: input.optOutUrl, optOutLabel: "No thanks, take me off this list" } : {}),
86
+ },
87
+ });
88
+ return { memberId: input.member.id, jobId: result.jobId, copy };
89
+ }
@@ -0,0 +1,285 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { hasPlatformDevice, type TesterActivity } from "../activity/resolve";
5
+ import { type DayKey, dayKey, daysBetween, daysSince } from "../clock/days";
6
+ import type { CohortClock } from "../clock/replay";
7
+ import type { TestersConfig } from "../config/config";
8
+ import type { TestersCohort } from "../data/cohort";
9
+ import type { TestersMember } from "../data/member";
10
+ import type { NudgeTally, TestersCohortSnapshot } from "../data/snapshot";
11
+ import { bandFor, type HealthResult, scoreHealth, survivalFor } from "../health/score";
12
+ import { type ForecastMember, forecastCohort, type PipelineMember } from "./forecast";
13
+ import { cohortAgeDays, conversionLatency, median } from "./inputs";
14
+ import { computeTrend, type TrendPoint } from "./trend";
15
+
16
+ /**
17
+ * Composing one day's snapshot from every part: the clock, the activity, the health scores, the
18
+ * forecast, and the trend.
19
+ *
20
+ * This is a pure function of its inputs. Everything that touches D1 — reading the events, resolving
21
+ * activity against auth, upserting the row — lives in the Workflow that calls it. The separation is
22
+ * what makes a snapshot testable against hand-built rosters rather than only against a database, and a
23
+ * snapshot is the row the entire dashboard trend is drawn from, so it is worth being able to assert
24
+ * exactly.
25
+ */
26
+
27
+ /** One member, paired with whatever we could observe about them. */
28
+ export interface MemberReading {
29
+ readonly member: TestersMember;
30
+ readonly activity: TesterActivity;
31
+ }
32
+
33
+ /** Everything the builder needs for one cohort on one day. */
34
+ export interface BuildInput {
35
+ readonly cohort: TestersCohort;
36
+ readonly config: TestersConfig;
37
+ readonly clock: CohortClock;
38
+ readonly readings: readonly MemberReading[];
39
+ /** Yesterday's snapshot, for the one-day deltas. */
40
+ readonly yesterday: TrendPoint | null;
41
+ /**
42
+ * Whether yesterday's snapshot recorded the cohort at target.
43
+ *
44
+ * The persisted `resetToday` is computed against this rather than against the replayed previous day.
45
+ * The replay's own `resetToday` is correct for a live read but wrong to store: the pass runs at 05:00,
46
+ * so by the time it writes day D+1 the replay of day D already reflects the lapse, and the break gets
47
+ * annotated on no snapshot at all. Comparing snapshot to snapshot catches it wherever in the day it
48
+ * happened.
49
+ */
50
+ readonly yesterdayMetTarget: boolean | null;
51
+ /** The snapshot from seven days ago, for the weekly deltas the trend rule reads. */
52
+ readonly weekAgo: TrendPoint | null;
53
+ /** How many snapshots this cohort has, including the one being built. */
54
+ readonly snapshotCount: number;
55
+ /** Nudges enqueued on this day, by kind. */
56
+ readonly nudgesSent: NudgeTally;
57
+ /** How many addresses were newly found unreachable today. */
58
+ readonly bouncedCount: number;
59
+ /** How many observed testers first went dark this week — for the trend sentence. */
60
+ readonly newlyDarkCount: number;
61
+ /** The moment the pass ran. */
62
+ readonly now: Date;
63
+ }
64
+
65
+ /** A scored member: the reading, its health, and the survival prior it selects. */
66
+ interface ScoredMember extends MemberReading {
67
+ readonly health: HealthResult;
68
+ readonly dailySurvival: number;
69
+ }
70
+
71
+ /**
72
+ * The most recent thing that proves a tester is still there.
73
+ *
74
+ * The dark clock floors at the opt-in date. Without that floor a tester who confirmed three hours ago
75
+ * measures their darkness from the day they were invited and arrives reading as critical — opting in is
76
+ * a GET on a public route and creates no session, so it leaves no authentication behind.
77
+ *
78
+ * One function because three places need the answer — the health score, the snapshot's darkness
79
+ * histogram, and the live member view — and a row whose histogram and health score disagree about the
80
+ * same tester is worse than either being wrong on its own.
81
+ */
82
+ export function lastSignOfLife(reading: {
83
+ member: { optedInAt: Date | null };
84
+ activity: { lastAuthenticatedAt: Date | null };
85
+ }): Date | null {
86
+ return [reading.activity.lastAuthenticatedAt, reading.member.optedInAt]
87
+ .filter((date): date is Date => date !== null)
88
+ .reduce<Date | null>((latest, date) => (latest === null || date > latest ? date : latest), null);
89
+ }
90
+
91
+ /** Score every member against the cohort's own rules. */
92
+ function scoreAll(input: BuildInput): ScoredMember[] {
93
+ return input.readings.map((reading) => {
94
+ const optedInAt = reading.member.optedInAt;
95
+ const signOfLife = lastSignOfLife(reading);
96
+
97
+ const health = scoreHealth(
98
+ {
99
+ observability: reading.activity.observability,
100
+ daysDark:
101
+ reading.activity.observability === "observed" && signOfLife !== null
102
+ ? daysSince(signOfLife, input.now)
103
+ : null,
104
+ sessionsInWindow: reading.activity.sessionsInWindow,
105
+ deviceCount: reading.activity.devices.length,
106
+ hasTargetPlatformDevice: hasPlatformDevice(reading.activity, input.cohort.targetPlatform),
107
+ sessionSinceOptIn:
108
+ optedInAt !== null &&
109
+ reading.activity.lastAuthenticatedAt !== null &&
110
+ reading.activity.lastAuthenticatedAt >= optedInAt,
111
+ unansweredNudges: reading.member.nudgeCount,
112
+ daysSinceOptIn: optedInAt === null ? null : daysSince(optedInAt, input.now),
113
+ targetPlatform: input.cohort.targetPlatform,
114
+ },
115
+ input.config.healthPenalties,
116
+ input.config.healthCredits,
117
+ );
118
+
119
+ return {
120
+ ...reading,
121
+ health,
122
+ dailySurvival: survivalFor(health.riskBand, reading.activity.observability, input.config.survival),
123
+ };
124
+ });
125
+ }
126
+
127
+ /** The median of a list, or null when it is empty. */
128
+ /** Build one day's snapshot. */
129
+ export function buildSnapshot(input: BuildInput): TestersCohortSnapshot {
130
+ const scored = scoreAll(input);
131
+ const today = dayKey(input.now);
132
+
133
+ const optedIn = scored.filter((entry) => entry.member.state === "opted_in");
134
+ const invited = scored.filter((entry) => entry.member.state === "invited");
135
+ const accepted = scored.filter((entry) => entry.member.state === "accepted");
136
+ const lapsed = scored.filter((entry) => entry.member.state === "lapsed" || entry.member.state === "removed");
137
+ const unreachable = scored.filter((entry) => entry.member.unreachable);
138
+
139
+ const observed = optedIn.filter((entry) => entry.activity.observability === "observed");
140
+ const neverLinked = optedIn.filter((entry) => entry.activity.state === "never_linked");
141
+ const coverage = optedIn.length === 0 ? 0 : observed.length / optedIn.length;
142
+
143
+ // The same floor `scoreAll` and `memberView` apply, and it has to be the same or the row disagrees
144
+ // with itself. Opting in is a GET on a public route and creates no session, so any tester whose
145
+ // confirmation is more recent than their last sign-in reads as long-dark to the histogram while the
146
+ // health score on the same row scores them as barely penalized.
147
+ const darkDays = (entry: ScoredMember): number | null => {
148
+ if (entry.activity.observability !== "observed" || !entry.activity.lastAuthenticatedAt) return null;
149
+ return daysSince(lastSignOfLife(entry) ?? entry.activity.lastAuthenticatedAt, input.now);
150
+ };
151
+
152
+ const scores = observed.map((entry) => entry.health.health).filter((health): health is number => health !== null);
153
+
154
+ const forecastMembers: ForecastMember[] = optedIn.map((entry) => ({
155
+ riskBand: entry.health.riskBand,
156
+ observability: entry.activity.observability,
157
+ dailySurvival: entry.dailySurvival,
158
+ }));
159
+
160
+ const pipeline: PipelineMember[] = [...invited, ...accepted].map((entry) => ({
161
+ stage: entry.member.state === "accepted" ? "accepted" : "invited",
162
+ unreachable: entry.member.unreachable,
163
+ }));
164
+
165
+ const latency = conversionLatency(scored.map((entry) => entry.member));
166
+
167
+ const forecast = forecastCohort({
168
+ today,
169
+ targetSize: input.cohort.targetSize,
170
+ optedInCount: input.clock.estimatedOptedInCount,
171
+ daysRemaining: input.clock.estimatedDaysRemaining,
172
+ members: forecastMembers,
173
+ pipeline,
174
+ optedInEver: scored.filter((entry) => entry.member.optedInAt !== null).length,
175
+ invitedEver: scored.length,
176
+ medianConversionDays: latency.medianConversionDays,
177
+ conversionSampleSize: latency.conversionSampleSize,
178
+ cohortAgeDays: cohortAgeDays(input.cohort.createdAt, today),
179
+ maxRosterSize: input.cohort.maxRosterSize,
180
+ });
181
+
182
+ const weakMemberCount = optedIn.filter(
183
+ (entry) => entry.health.riskBand === "at_risk" || entry.health.riskBand === "critical",
184
+ ).length;
185
+
186
+ const trend = computeTrend({
187
+ today: {
188
+ estimatedOptedInCount: input.clock.estimatedOptedInCount,
189
+ activeCount: observed.filter((entry) => entry.activity.state === "active").length,
190
+ successProbability: forecast.successProbability,
191
+ },
192
+ yesterday: input.yesterday,
193
+ weekAgo: input.weekAgo,
194
+ snapshotCount: input.snapshotCount,
195
+ atTargetWithNoHeadroom: input.clock.meetsTarget && input.clock.headroom === 0,
196
+ weakMemberCount,
197
+ newlyDarkCount: input.newlyDarkCount,
198
+ });
199
+
200
+ const inBand = (low: number, high: number) =>
201
+ observed.filter((entry) => {
202
+ const days = darkDays(entry);
203
+ return days !== null && days >= low && days <= high;
204
+ }).length;
205
+
206
+ const snapshotOn: DayKey = today;
207
+
208
+ return {
209
+ id: 0,
210
+ cohortId: input.cohort.id,
211
+ snapshotOn,
212
+ dayIndex: Math.max(0, daysBetween(dayKey(input.cohort.createdAt), snapshotOn)),
213
+ computedAt: input.now,
214
+ // A pass writing a day that has already ended is a replay or a catch-up. The opt-in figures are
215
+ // still exact — they replay from events — but the activity figures are whatever survived, so a
216
+ // chart must render the point dashed rather than treat it as a measurement.
217
+ backfilled: false,
218
+ modelVersion: input.config.modelVersion,
219
+
220
+ rosterSize: scored.length,
221
+ invitedCount: invited.length,
222
+ acceptedCount: accepted.length,
223
+ estimatedOptedInCount: input.clock.estimatedOptedInCount,
224
+ lapsedCount: lapsed.length,
225
+ unreachableCount: unreachable.length,
226
+
227
+ targetSize: input.cohort.targetSize,
228
+ windowDays: input.cohort.windowDays,
229
+ meetsTarget: input.clock.meetsTarget,
230
+ headroom: input.clock.headroom,
231
+ estimatedHeldDays: input.clock.estimatedHeldDays,
232
+ estimatedWindowStartOn: input.clock.estimatedWindowStartOn,
233
+ estimatedDaysRemaining: input.clock.estimatedDaysRemaining,
234
+ resetCount: input.clock.resetCount,
235
+ // Snapshot-to-snapshot, not replay-to-replay. See `yesterdayMetTarget` for why.
236
+ resetToday:
237
+ input.yesterdayMetTarget === null ? input.clock.resetToday : input.yesterdayMetTarget && !input.clock.meetsTarget,
238
+
239
+ observedCount: observed.length,
240
+ neverLinkedCount: neverLinked.length,
241
+ observedCoverage: coverage,
242
+ activeCount: observed.filter((entry) => entry.activity.state === "active").length,
243
+ darkThreeToSevenCount: inBand(3, 7),
244
+ darkEightToThirteenCount: inBand(8, 13),
245
+ darkFourteenPlusCount: inBand(14, Number.POSITIVE_INFINITY),
246
+ sessionsInWindow: scored.reduce((sum, entry) => sum + entry.activity.sessionsInWindow, 0),
247
+ targetPlatformDeviceCount: scored.filter((entry) => hasPlatformDevice(entry.activity, input.cohort.targetPlatform))
248
+ .length,
249
+
250
+ healthyCount: scores.filter((score) => bandFor(score) === "healthy").length,
251
+ watchCount: scores.filter((score) => bandFor(score) === "watch").length,
252
+ atRiskCount: scores.filter((score) => bandFor(score) === "at_risk").length,
253
+ criticalCount: scores.filter((score) => bandFor(score) === "critical").length,
254
+ unknownHealthCount: optedIn.length - observed.length,
255
+ medianHealth: median(scores),
256
+ // The minimum, not just the median: a twelve-of-twelve cohort breaks at its weakest link, so the
257
+ // worst tester is more informative than the typical one.
258
+ minHealth: scores.length === 0 ? null : Math.min(...scores),
259
+
260
+ expectedSurvivors: forecast.expectedSurvivors,
261
+ probabilityReachTarget: forecast.probabilityReachTarget,
262
+ probabilityHoldWindow: forecast.probabilityHoldWindow,
263
+ successProbability: forecast.successProbability,
264
+ successProbabilityLow: forecast.successProbabilityLow,
265
+ successProbabilityHigh: forecast.successProbabilityHigh,
266
+ confidence: forecast.confidence,
267
+ basis: forecast.basis,
268
+ projectedTargetMetOn: forecast.projectedTargetMetOn,
269
+ projectedCompleteOn: forecast.projectedCompleteOn,
270
+ invitesNeeded: forecast.invitesNeeded,
271
+ recommendedRosterSize: forecast.recommendedRosterSize,
272
+
273
+ optedInDelta1d: trend.optedInDelta1d,
274
+ optedInDelta7d: trend.optedInDelta7d,
275
+ activeDelta7d: trend.activeDelta7d,
276
+ successProbabilityDelta1d: trend.successProbabilityDelta1d,
277
+ successProbabilityDelta7d: trend.successProbabilityDelta7d,
278
+ trendDirection: trend.direction,
279
+ fragile: trend.fragile,
280
+ trendReason: trend.reason,
281
+
282
+ nudgesSent: input.nudgesSent,
283
+ bouncedCount: input.bouncedCount,
284
+ };
285
+ }