@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,185 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { TrendDirection } from "../data/enums";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The trend: which way a cohort is heading, and one sentence saying why.
|
|
8
|
+
*
|
|
9
|
+
* **A published rule, not a fit.** Three inputs, evaluated in a fixed order, with the thresholds
|
|
10
|
+
* written down. A developer looking at a red arrow can work out exactly what produced it, which is the
|
|
11
|
+
* whole difference between a signal they will act on and one they will learn to ignore.
|
|
12
|
+
*
|
|
13
|
+
* **The deltas are precomputed onto the snapshot row rather than derived on read.** That is what lets a
|
|
14
|
+
* summary card render from one row — no client-side series arithmetic, and therefore no way for the
|
|
15
|
+
* card and the chart to disagree about the same cohort. It also means the delta recorded on a day is
|
|
16
|
+
* the delta as it was computed on that day, which survives a later correction to history.
|
|
17
|
+
*
|
|
18
|
+
* **`fragile` is deliberately not a direction.** A cohort can be improving and fragile at the same
|
|
19
|
+
* time — three new opt-ins this week and still sitting at exactly twelve with someone dark for nine
|
|
20
|
+
* days — and collapsing that into one enum would lose whichever of the two is more urgent. It gets its
|
|
21
|
+
* own boolean and earns its own badge.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Fewer snapshots than this and there is no trend, only noise. */
|
|
25
|
+
const MIN_SNAPSHOTS_FOR_TREND = 3;
|
|
26
|
+
|
|
27
|
+
/** A forecast move of at least this much over a week is a direction rather than a wobble. */
|
|
28
|
+
const SIGNIFICANT_PROBABILITY_DELTA = 0.05;
|
|
29
|
+
|
|
30
|
+
/** One earlier snapshot, reduced to what the trend rule reads. */
|
|
31
|
+
export interface TrendPoint {
|
|
32
|
+
readonly estimatedOptedInCount: number;
|
|
33
|
+
readonly activeCount: number;
|
|
34
|
+
readonly successProbability: number | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Today's position, plus the history to measure it against. */
|
|
38
|
+
export interface TrendInput {
|
|
39
|
+
readonly today: TrendPoint;
|
|
40
|
+
/** Yesterday's snapshot, or null if there is none. */
|
|
41
|
+
readonly yesterday: TrendPoint | null;
|
|
42
|
+
/** The snapshot from seven days ago, or null if the cohort is younger than that. */
|
|
43
|
+
readonly weekAgo: TrendPoint | null;
|
|
44
|
+
/** How many snapshots exist in total, including today's. */
|
|
45
|
+
readonly snapshotCount: number;
|
|
46
|
+
/** Whether the cohort is at target with zero headroom right now. */
|
|
47
|
+
readonly atTargetWithNoHeadroom: boolean;
|
|
48
|
+
/** How many currently opted-in testers are at-risk or critical. */
|
|
49
|
+
readonly weakMemberCount: number;
|
|
50
|
+
/** How many testers went dark in the last week — for the sentence, not the direction. */
|
|
51
|
+
readonly newlyDarkCount: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The computed trend: the deltas, the direction, the fragility flag, and the sentence. */
|
|
55
|
+
export interface Trend {
|
|
56
|
+
readonly optedInDelta1d: number | null;
|
|
57
|
+
readonly optedInDelta7d: number | null;
|
|
58
|
+
readonly activeDelta7d: number | null;
|
|
59
|
+
readonly successProbabilityDelta1d: number | null;
|
|
60
|
+
readonly successProbabilityDelta7d: number | null;
|
|
61
|
+
readonly direction: TrendDirection;
|
|
62
|
+
readonly fragile: boolean;
|
|
63
|
+
/** One brand-voice sentence explaining the direction, for rendering beside the arrow. */
|
|
64
|
+
readonly reason: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A difference, or null when either end is missing. */
|
|
68
|
+
function delta(current: number | null, previous: number | null | undefined): number | null {
|
|
69
|
+
if (current === null || previous === null || previous === undefined) return null;
|
|
70
|
+
return current - previous;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Compute the trend. */
|
|
74
|
+
export function computeTrend(input: TrendInput): Trend {
|
|
75
|
+
const optedInDelta1d = input.yesterday
|
|
76
|
+
? input.today.estimatedOptedInCount - input.yesterday.estimatedOptedInCount
|
|
77
|
+
: null;
|
|
78
|
+
const optedInDelta7d = input.weekAgo ? input.today.estimatedOptedInCount - input.weekAgo.estimatedOptedInCount : null;
|
|
79
|
+
const activeDelta7d = input.weekAgo ? input.today.activeCount - input.weekAgo.activeCount : null;
|
|
80
|
+
const probabilityDelta1d = delta(input.today.successProbability, input.yesterday?.successProbability);
|
|
81
|
+
const probabilityDelta7d = delta(input.today.successProbability, input.weekAgo?.successProbability);
|
|
82
|
+
|
|
83
|
+
const fragile = input.atTargetWithNoHeadroom && input.weakMemberCount > 0;
|
|
84
|
+
|
|
85
|
+
// The week is the preferred window, and yesterday is the fallback rather than an addition. A cohort
|
|
86
|
+
// with three to seven snapshots has no `weekAgo` at all — the lookup is an exact-day match — so the
|
|
87
|
+
// weekly deltas are null through the whole first week, and a rule reading only them had nothing to
|
|
88
|
+
// read for half the window it exists to watch.
|
|
89
|
+
const window: TrendWindow =
|
|
90
|
+
optedInDelta7d !== null || probabilityDelta7d !== null
|
|
91
|
+
? { span: "week", optedIn: optedInDelta7d, probability: probabilityDelta7d }
|
|
92
|
+
: { span: "day", optedIn: optedInDelta1d, probability: probabilityDelta1d };
|
|
93
|
+
|
|
94
|
+
const { direction, reason } = classify(input, window, fragile);
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
optedInDelta1d,
|
|
98
|
+
optedInDelta7d,
|
|
99
|
+
activeDelta7d,
|
|
100
|
+
successProbabilityDelta1d: probabilityDelta1d,
|
|
101
|
+
successProbabilityDelta7d: probabilityDelta7d,
|
|
102
|
+
direction,
|
|
103
|
+
fragile,
|
|
104
|
+
reason,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Whichever comparison the rule had data for, and how to say it. */
|
|
109
|
+
interface TrendWindow {
|
|
110
|
+
readonly span: "week" | "day";
|
|
111
|
+
readonly optedIn: number | null;
|
|
112
|
+
readonly probability: number | null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The rule itself, evaluated in order, with the sentence each branch produces. */
|
|
116
|
+
function classify(
|
|
117
|
+
input: TrendInput,
|
|
118
|
+
window: TrendWindow,
|
|
119
|
+
fragile: boolean,
|
|
120
|
+
): { direction: TrendDirection; reason: string } {
|
|
121
|
+
// Two points make a straight line through noise. Say so rather than draw it.
|
|
122
|
+
if (input.snapshotCount < MIN_SNAPSHOTS_FOR_TREND) {
|
|
123
|
+
return { direction: "unknown", reason: "Not enough history yet." };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Neither window had an end to measure against. `steady` is a claim, and the deltas go null
|
|
127
|
+
// specifically so that absent data never renders as "no change" — the direction has to obey the same
|
|
128
|
+
// rule the numbers do.
|
|
129
|
+
if (window.optedIn === null && window.probability === null) {
|
|
130
|
+
return { direction: "unknown", reason: "Not enough history yet." };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const since = window.span === "week" ? "this week" : "since yesterday";
|
|
134
|
+
const optedInDelta7d = window.optedIn;
|
|
135
|
+
const probabilityDelta7d = window.probability;
|
|
136
|
+
|
|
137
|
+
const losingTesters = optedInDelta7d !== null && optedInDelta7d < 0;
|
|
138
|
+
const losingConfidence = probabilityDelta7d !== null && probabilityDelta7d <= -SIGNIFICANT_PROBABILITY_DELTA;
|
|
139
|
+
|
|
140
|
+
if (losingTesters || losingConfidence) {
|
|
141
|
+
if (losingTesters) {
|
|
142
|
+
const lost = Math.abs(optedInDelta7d as number);
|
|
143
|
+
return {
|
|
144
|
+
direction: "declining",
|
|
145
|
+
reason: lost === 1 ? `One tester dropped out ${since}.` : `${lost} testers dropped out ${since}.`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (input.newlyDarkCount > 0) {
|
|
149
|
+
// Not `since` — this one is not a windowed delta. `newlyDark` counts testers whose darkness is
|
|
150
|
+
// *currently* between three and seven days, measured against now alone, so every one of them
|
|
151
|
+
// went quiet at least three days ago. Saying "since yesterday" about that would be false by
|
|
152
|
+
// construction on any cohort young enough to take the daily window.
|
|
153
|
+
return {
|
|
154
|
+
direction: "declining",
|
|
155
|
+
reason:
|
|
156
|
+
input.newlyDarkCount === 1
|
|
157
|
+
? "One tester has gone quiet this week."
|
|
158
|
+
: `${input.newlyDarkCount} testers have gone quiet this week.`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
return { direction: "declining", reason: `The forecast has fallen ${since}.` };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const gainingTesters = optedInDelta7d !== null && optedInDelta7d > 0;
|
|
165
|
+
const gainingConfidence = probabilityDelta7d !== null && probabilityDelta7d >= SIGNIFICANT_PROBABILITY_DELTA;
|
|
166
|
+
|
|
167
|
+
if (gainingTesters || gainingConfidence) {
|
|
168
|
+
if (gainingTesters) {
|
|
169
|
+
const gained = optedInDelta7d as number;
|
|
170
|
+
return {
|
|
171
|
+
direction: "improving",
|
|
172
|
+
reason: gained === 1 ? `One more opt-in ${since}.` : `${gained} more opt-ins ${since}.`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
return { direction: "improving", reason: `The forecast has risen ${since}.` };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Steady is the residual, and it still has two flavours worth distinguishing: holding comfortably,
|
|
179
|
+
// and holding with nothing to spare. The second reads as fine on a count and is one lapse from a
|
|
180
|
+
// reset, which is exactly the state a developer needs told rather than left to infer.
|
|
181
|
+
if (fragile) {
|
|
182
|
+
return { direction: "steady", reason: "Holding at target. No headroom." };
|
|
183
|
+
}
|
|
184
|
+
return { direction: "steady", reason: `Steady ${since}.` };
|
|
185
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { resourceNames } from "@pithy-sh/core/src/naming/resourceNames";
|
|
5
|
+
import type { ManagedEnvironment } from "@pithy-sh/secrets/src/scope";
|
|
6
|
+
import { TESTERS_CAPABILITY } from "../workflows/specs";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The provisioning orchestration for the testers capability — the live counterpart to
|
|
10
|
+
* `pithy add testers`'s config wiring.
|
|
11
|
+
*
|
|
12
|
+
* The split is the same one every capability makes, and worth restating because the two commands look
|
|
13
|
+
* like one. `pithy add` writes *bindings*: it installs the package, adds `testers()` to
|
|
14
|
+
* `pithy.config.ts`, and puts a `DB` entry in `wrangler.jsonc`. It touches no Cloudflare account, so
|
|
15
|
+
* adding a capability stays something you can do offline, in CI, or without credentials. This command
|
|
16
|
+
* stands up the one thing those bindings point at: the prebuilt worker that hosts the daily pass.
|
|
17
|
+
*
|
|
18
|
+
* The `TESTERS_DAILY` Workflow binding belongs to this side of the split for a mechanical reason.
|
|
19
|
+
* Wrangler requires a `name` and a `class_name` on every `workflows` entry, and the deployed name is
|
|
20
|
+
* per environment — so `add` writes none, because a partial entry stops wrangler loading the config at
|
|
21
|
+
* all, and the CLI writes the complete entry here once the host exists.
|
|
22
|
+
*
|
|
23
|
+
* **This provisioner is unusually small, and the reason is worth stating.** There is no bucket to
|
|
24
|
+
* create, no index, and — since the confirmation token became a random value on the tester's own row
|
|
25
|
+
* rather than a signature — no secret to write and no master key to bind. What is left is a template,
|
|
26
|
+
* a deploy, and a binding. A capability that needs less provisioning is a capability an adopter is more
|
|
27
|
+
* likely to actually finish setting up.
|
|
28
|
+
*
|
|
29
|
+
* The live Cloudflare/wrangler steps sit behind the {@link TestersProvisioner} seam, so the
|
|
30
|
+
* orchestration — order, idempotency, per-environment fan-out — is unit-tested with a fake that records
|
|
31
|
+
* its call order, and no account. **Every step is idempotent**: the deploy overwrites, and the binding
|
|
32
|
+
* write is a find-or-append. Re-running is a no-op.
|
|
33
|
+
*
|
|
34
|
+
* **What it deliberately does not do.** It does not run the pass. A provision that also fired the daily
|
|
35
|
+
* job would mail an adopter's testers as a side effect of a deployment command, which is the kind of
|
|
36
|
+
* surprise that costs a sending domain its reputation. `pithy testers run` is the explicit way.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The deployed daily-pass worker name for a project's environment — also its resolved config's basename.
|
|
41
|
+
*
|
|
42
|
+
* Through core's naming facade under the **`worker`** namespace: the kind of thing carries the cap, so
|
|
43
|
+
* this cannot be measured against some other namespace's number, and the environment is validated here
|
|
44
|
+
* rather than at the deploy that would have used it.
|
|
45
|
+
*/
|
|
46
|
+
export function testersWorkerName(project: string, env: ManagedEnvironment): string {
|
|
47
|
+
return resourceNames(project).env(env).worker(TESTERS_CAPABILITY);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The live Cloudflare/wrangler seam. Each step must be idempotent.
|
|
52
|
+
*
|
|
53
|
+
* Narrow on purpose: everything a fake needs to stand in for is an account check and a deploy, which is
|
|
54
|
+
* what makes the orchestration testable without a Cloudflare account.
|
|
55
|
+
*/
|
|
56
|
+
export interface TestersProvisioner {
|
|
57
|
+
/** Fail before the first deploy if the account cannot host a Workflow at all. */
|
|
58
|
+
preflight(): Promise<void>;
|
|
59
|
+
/** Resolve the committed template for this environment and `wrangler deploy` it. */
|
|
60
|
+
deployWorker(env: ManagedEnvironment): Promise<void>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The live seam for taking a deployed host back down. */
|
|
64
|
+
export interface TestersDeprovisioner {
|
|
65
|
+
/** Delete this environment's daily-pass worker. Absent is success — teardown is idempotent. */
|
|
66
|
+
deleteWorker(env: ManagedEnvironment): Promise<void>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** What one provisioning run did, per environment, for the command to report. */
|
|
70
|
+
export interface TestersProvisionResult {
|
|
71
|
+
readonly env: ManagedEnvironment;
|
|
72
|
+
readonly worker: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Provision the daily-pass host across every managed environment.
|
|
77
|
+
*
|
|
78
|
+
* Preflight runs once, before any deploy. Failing there means failing before one environment is half
|
|
79
|
+
* provisioned rather than part way through the fan-out — the state that is hardest to reason about and
|
|
80
|
+
* hardest to recover from.
|
|
81
|
+
*/
|
|
82
|
+
export async function provisionTesters(
|
|
83
|
+
provisioner: TestersProvisioner,
|
|
84
|
+
project: string,
|
|
85
|
+
environments: readonly ManagedEnvironment[],
|
|
86
|
+
): Promise<TestersProvisionResult[]> {
|
|
87
|
+
await provisioner.preflight();
|
|
88
|
+
|
|
89
|
+
const results: TestersProvisionResult[] = [];
|
|
90
|
+
for (const env of environments) {
|
|
91
|
+
await provisioner.deployWorker(env);
|
|
92
|
+
results.push({ env, worker: testersWorkerName(project, env) });
|
|
93
|
+
}
|
|
94
|
+
return results;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Take every environment's host back down. Idempotent: a worker that is already gone is success. */
|
|
98
|
+
export async function deprovisionTesters(
|
|
99
|
+
deprovisioner: TestersDeprovisioner,
|
|
100
|
+
project: string,
|
|
101
|
+
environments: readonly ManagedEnvironment[],
|
|
102
|
+
): Promise<TestersProvisionResult[]> {
|
|
103
|
+
const results: TestersProvisionResult[] = [];
|
|
104
|
+
for (const env of environments) {
|
|
105
|
+
await deprovisioner.deleteWorker(env);
|
|
106
|
+
results.push({ env, worker: testersWorkerName(project, env) });
|
|
107
|
+
}
|
|
108
|
+
return results;
|
|
109
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { LocaleCatalogs } from "@pithy-sh/core/src/i18n/catalog";
|
|
5
|
+
import { hostWorkflowsFor, resolveWorkflowHost, type WorkflowHostTemplate } from "@pithy-sh/core/src/workflow/host";
|
|
6
|
+
import { suppressionDatabaseName } from "@pithy-sh/email/src/provision/provisionEmail";
|
|
7
|
+
import { emailMessagesVars } from "@pithy-sh/email/src/provision/resolveEmailConfig";
|
|
8
|
+
import type { ManagedEnvironment } from "@pithy-sh/secrets/src/scope";
|
|
9
|
+
import type { TestersConfig } from "../config/config";
|
|
10
|
+
import { TESTERS_CAPABILITY, testersWorkflowRegistry } from "../workflows/specs";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolve the daily-pass worker's committed `wrangler.jsonc` template into one environment's standalone
|
|
14
|
+
* config. Every per-environment decision lives here; everything static — the compatibility date, the
|
|
15
|
+
* binding names — stays as the template committed it.
|
|
16
|
+
*
|
|
17
|
+
* Thin over core's {@link resolveWorkflowHost}, which owns the mechanics. What this file adds is the
|
|
18
|
+
* one thing the generic resolver deliberately does not do: it **rewrites `workflows` and
|
|
19
|
+
* `triggers.crons` from the capability's own specs** rather than from the template's block. The
|
|
20
|
+
* template carries both so it reads as a complete, deployable config, but `workflows/specs.ts` is the
|
|
21
|
+
* single source of the binding name, the class name, and the schedule — so moving the pass off 05:00
|
|
22
|
+
* is a one-line spec edit, not a spec edit plus a JSONC edit that nothing checks agree.
|
|
23
|
+
*
|
|
24
|
+
* Pure: the caller parses the template and writes the result.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** The sending identity the host needs to enqueue mail, copied from the email capability's config. */
|
|
28
|
+
export interface TestersEmailIdentity {
|
|
29
|
+
/** The from address. Must be on a domain onboarded to Cloudflare Email Service. */
|
|
30
|
+
readonly fromAddress: string;
|
|
31
|
+
/** The from display name recipients see. */
|
|
32
|
+
readonly fromName: string;
|
|
33
|
+
/** The resolved email theme, serialized into the host's `EMAIL_THEME` var. */
|
|
34
|
+
readonly theme: unknown;
|
|
35
|
+
/**
|
|
36
|
+
* The project's email catalogs, from the composed email capability's `hostCatalogs()` — serialized
|
|
37
|
+
* into the host's `EMAIL_MESSAGES` var.
|
|
38
|
+
*
|
|
39
|
+
* The same journey the theme makes, and it has to be: this host composes nothing. `nudge/enqueueSeam.ts`
|
|
40
|
+
* reads `env.EMAIL_MESSAGES` "exactly as the email host worker carries them", and until this field
|
|
41
|
+
* existed nothing wrote it — so the nudge shell was the kit's English however many languages the
|
|
42
|
+
* project spoke, which is the pithy-sh/pithy#441 defect surviving on the second Worker that reads
|
|
43
|
+
* the var.
|
|
44
|
+
*
|
|
45
|
+
* The **shell** is what it moves — the document's `lang` and `dir`, the footer's opt-out word. A
|
|
46
|
+
* nudge's own words are the adopter's copy, supplied per message and never in a catalog.
|
|
47
|
+
*
|
|
48
|
+
* **Required, and `{}` is the way to say "none".** `CloudflareEmailProvisionerOptions.messages` is
|
|
49
|
+
* required for the same reason and it is the same reason twice: a provisioner that forgot the
|
|
50
|
+
* catalogs is exactly what left this var written by nobody, and a field carrying a default cannot be
|
|
51
|
+
* forgotten out loud. Empty writes no var at all, which is the host's own spelling of "the English I
|
|
52
|
+
* bundle" and what a project serving one language wants.
|
|
53
|
+
*/
|
|
54
|
+
readonly messages: LocaleCatalogs;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The resolved ids and per-env values for one environment's daily-pass deploy. */
|
|
58
|
+
export interface TestersConfigParams {
|
|
59
|
+
/**
|
|
60
|
+
* The project name — the `<project>` segment the deployed host, its daily Workflow, and the
|
|
61
|
+
* suppression database name all lead with. The root `pithy.config.ts` `name`, resolved by
|
|
62
|
+
* `requireProjectName` and never guessed.
|
|
63
|
+
*/
|
|
64
|
+
readonly project: string;
|
|
65
|
+
/** The target environment. */
|
|
66
|
+
readonly env: ManagedEnvironment;
|
|
67
|
+
/** The app database id — where the `pithy_testers_*`, `pithy_auth_*` and `pithy_email_jobs` tables live. */
|
|
68
|
+
readonly appDatabaseId: string;
|
|
69
|
+
/**
|
|
70
|
+
* The project's email-suppression database id.
|
|
71
|
+
*
|
|
72
|
+
* Shared across environments rather than per environment, matching how `@pithy-sh/email` provisions
|
|
73
|
+
* it: an unsubscribe in prod must stop staging too, so every environment's host binds the same
|
|
74
|
+
* database. Shared across *projects* it is not — the name carries the project, so one product's
|
|
75
|
+
* opt-out list can no longer suppress another's transactional mail.
|
|
76
|
+
*/
|
|
77
|
+
readonly suppressionDatabaseId: string;
|
|
78
|
+
/** The app's resolved testers config — serialized into the host's `TESTERS_CONFIG` var. */
|
|
79
|
+
readonly testersConfig: TestersConfig;
|
|
80
|
+
/**
|
|
81
|
+
* The sending identity, or undefined when the project composes no email capability.
|
|
82
|
+
*
|
|
83
|
+
* Undefined is a legitimate state rather than an error: the pass still advances roster state and
|
|
84
|
+
* writes its snapshot, it simply sends nothing. A default address would be worse than an absent one —
|
|
85
|
+
* mail from a domain the adopter's DKIM does not cover trains recipients' providers to distrust the
|
|
86
|
+
* real domain, which is the opposite of what this capability is for.
|
|
87
|
+
*/
|
|
88
|
+
readonly email: TestersEmailIdentity | undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Fill the template for one environment. */
|
|
92
|
+
export function resolveTestersConfig(
|
|
93
|
+
template: WorkflowHostTemplate,
|
|
94
|
+
params: TestersConfigParams,
|
|
95
|
+
): WorkflowHostTemplate {
|
|
96
|
+
const { project, env, appDatabaseId, suppressionDatabaseId, testersConfig, email } = params;
|
|
97
|
+
|
|
98
|
+
// Derived before the resolve: `resolveWorkflowHost` refuses a template that declares `workflows`
|
|
99
|
+
// without them, because the only name it could invent unaided is one a second project would overwrite.
|
|
100
|
+
const derived = hostWorkflowsFor(testersWorkflowRegistry, { project, capability: TESTERS_CAPABILITY, env });
|
|
101
|
+
|
|
102
|
+
const resolved = resolveWorkflowHost(template, {
|
|
103
|
+
project,
|
|
104
|
+
capability: TESTERS_CAPABILITY,
|
|
105
|
+
env,
|
|
106
|
+
databaseIds: { DB: appDatabaseId, EMAIL_SUPPRESSIONS: suppressionDatabaseId },
|
|
107
|
+
// The suppression database is email's, and its name now carries the project. Left alone, the
|
|
108
|
+
// template would print `pithy-email-suppressions` — a name no account holds.
|
|
109
|
+
databaseNames: { EMAIL_SUPPRESSIONS: suppressionDatabaseName(project) },
|
|
110
|
+
workflows: derived.workflows,
|
|
111
|
+
vars: {
|
|
112
|
+
TESTERS_CONFIG: JSON.stringify(testersConfig),
|
|
113
|
+
// Only set when there is an identity to set. The removal below is the other half — see it for why
|
|
114
|
+
// omitting the key is not the same as not writing one.
|
|
115
|
+
...(email
|
|
116
|
+
? {
|
|
117
|
+
EMAIL_FROM_ADDRESS: email.fromAddress,
|
|
118
|
+
EMAIL_FROM_NAME: email.fromName,
|
|
119
|
+
EMAIL_THEME: JSON.stringify(email.theme),
|
|
120
|
+
// Through `@pithy-sh/email`'s own composer, not a local `JSON.stringify`: it is the same
|
|
121
|
+
// var against the same 5 KB Cloudflare ceiling on the same account, and error 10054 in
|
|
122
|
+
// the middle of a provision run is a wrangler exit nobody can act on. One refusal, one
|
|
123
|
+
// action line, for both hosts.
|
|
124
|
+
...emailMessagesVars(email.messages),
|
|
125
|
+
}
|
|
126
|
+
: {}),
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// The template declares the three `EMAIL_*` vars as `<filled-at-provision>` so it reads as a complete
|
|
131
|
+
// config, and `resolveWorkflowHost` merges rather than replaces — so *not writing* them leaves the
|
|
132
|
+
// placeholders in place rather than leaving them absent. The worker's `if (!env.EMAIL_FROM_ADDRESS)`
|
|
133
|
+
// guard reads a placeholder as an identity, and the failure lands one step later, inside a
|
|
134
|
+
// `JSON.parse` of `EMAIL_THEME`, in a catch that swallows it. A project that composes no email
|
|
135
|
+
// capability would deploy a host that looks configured to send and silently never does.
|
|
136
|
+
if (!email) {
|
|
137
|
+
for (const key of ["EMAIL_FROM_ADDRESS", "EMAIL_FROM_NAME", "EMAIL_THEME"]) delete resolved.vars?.[key];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// The configured hour, applied to the schedule the spec declares. The spec owns *that there is a
|
|
141
|
+
// daily cron and when it sits by default*; the adopter owns which hour it fires, and until this line
|
|
142
|
+
// existed `snapshotHourUtc` was a fully described, bounded, defaulted knob that nothing read — so
|
|
143
|
+
// setting it to 9 because 05:00 collided with their own nightly job changed nothing, silently.
|
|
144
|
+
const crons = derived.crons.map((cron) => {
|
|
145
|
+
const fields = cron.split(" ");
|
|
146
|
+
fields[1] = String(testersConfig.snapshotHourUtc);
|
|
147
|
+
return fields.join(" ");
|
|
148
|
+
});
|
|
149
|
+
// Only declare a cron block when a spec actually carries one. An empty `crons` array is a declaration
|
|
150
|
+
// wrangler honors, and a worker advertising a schedule it does not have is a deployment nobody can
|
|
151
|
+
// reason about.
|
|
152
|
+
resolved.triggers = crons.length > 0 ? { crons } : undefined;
|
|
153
|
+
|
|
154
|
+
return resolved;
|
|
155
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
5
|
+
import { type Logger, noopLogger } from "@pithy-sh/core/src/logger/logger";
|
|
6
|
+
import { resolveActivity, type TesterActivity } from "../activity/resolve";
|
|
7
|
+
import { dayKey, daysSince } from "../clock/days";
|
|
8
|
+
import { type CohortClock, readClock } from "../clock/replay";
|
|
9
|
+
import type { TestersConfig } from "../config/config";
|
|
10
|
+
import { TestersCohort } from "../data/cohort";
|
|
11
|
+
import { TestersEvent } from "../data/event";
|
|
12
|
+
import { TestersMember } from "../data/member";
|
|
13
|
+
import { TestersCohortSnapshot } from "../data/snapshot";
|
|
14
|
+
import {
|
|
15
|
+
TESTERS_COHORTS_TABLE,
|
|
16
|
+
TESTERS_EVENTS_TABLE,
|
|
17
|
+
TESTERS_MEMBERS_TABLE,
|
|
18
|
+
TESTERS_SNAPSHOTS_TABLE,
|
|
19
|
+
type TestersDatabase,
|
|
20
|
+
} from "../data/tables";
|
|
21
|
+
import { TestersCohortNotFoundError } from "../error/errors";
|
|
22
|
+
import type { MemberReading } from "../projection/build";
|
|
23
|
+
import { lastSignOfLife } from "../projection/build";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Reading a cohort: the roster, the replayed clock, and whatever activity we can observe.
|
|
27
|
+
*
|
|
28
|
+
* One module for the read side so the control-plane route, the tester's own status route, the CLI, and
|
|
29
|
+
* the daily Workflow all see the same numbers. A second implementation anywhere would eventually
|
|
30
|
+
* disagree with this one, and the disagreement would show up as a dashboard and a terminal reporting
|
|
31
|
+
* different day counts for the same cohort — which would destroy the credibility of both.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** Milliseconds in one day. */
|
|
35
|
+
const MS_PER_DAY = 86_400_000;
|
|
36
|
+
|
|
37
|
+
/** Everything read for one cohort. */
|
|
38
|
+
export interface CohortReading {
|
|
39
|
+
readonly cohort: TestersCohort;
|
|
40
|
+
readonly clock: CohortClock;
|
|
41
|
+
readonly readings: readonly MemberReading[];
|
|
42
|
+
/**
|
|
43
|
+
* The event log the clock was replayed from.
|
|
44
|
+
*
|
|
45
|
+
* Carried rather than discarded so a caller can replay the same events as of a different moment —
|
|
46
|
+
* the daily pass re-reads the finished previous day this way — without a second query for rows this
|
|
47
|
+
* read has already loaded.
|
|
48
|
+
*/
|
|
49
|
+
readonly events: readonly TestersEvent[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Load one cohort, or raise the capability's own 404. */
|
|
53
|
+
export async function requireCohort(db: TestersDatabase, cohortId: string): Promise<TestersCohort> {
|
|
54
|
+
const row = await db.selectFrom(TESTERS_COHORTS_TABLE).selectAll().where("id", "=", cohortId).executeTakeFirst();
|
|
55
|
+
if (!row) throw new TestersCohortNotFoundError({ detail: `no cohort ${cohortId}` });
|
|
56
|
+
return TestersCohort.parse(row);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Load every cohort, newest first. */
|
|
60
|
+
export async function listCohorts(db: TestersDatabase): Promise<TestersCohort[]> {
|
|
61
|
+
const rows = await db.selectFrom(TESTERS_COHORTS_TABLE).selectAll().orderBy("createdAt", "desc").execute();
|
|
62
|
+
return rows.map((row) => TestersCohort.parse(row));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Load a cohort's whole roster. */
|
|
66
|
+
export async function listMembers(db: TestersDatabase, cohortId: string): Promise<TestersMember[]> {
|
|
67
|
+
const rows = await db
|
|
68
|
+
.selectFrom(TESTERS_MEMBERS_TABLE)
|
|
69
|
+
.selectAll()
|
|
70
|
+
.where("cohortId", "=", cohortId)
|
|
71
|
+
.orderBy("invitedAt", "asc")
|
|
72
|
+
.execute();
|
|
73
|
+
return rows.map((row) => TestersMember.parse(row));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Load a cohort's whole event history, oldest first — the order the clock replays in. */
|
|
77
|
+
export async function listEvents(db: TestersDatabase, cohortId: string): Promise<TestersEvent[]> {
|
|
78
|
+
const rows = await db
|
|
79
|
+
.selectFrom(TESTERS_EVENTS_TABLE)
|
|
80
|
+
.selectAll()
|
|
81
|
+
.where("cohortId", "=", cohortId)
|
|
82
|
+
.orderBy("occurredAt", "asc")
|
|
83
|
+
.execute();
|
|
84
|
+
return rows.map((row) => TestersEvent.parse(row));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Load the trailing daily snapshots for a cohort, oldest first. */
|
|
88
|
+
export async function listSnapshots(
|
|
89
|
+
db: TestersDatabase,
|
|
90
|
+
cohortId: string,
|
|
91
|
+
limit: number,
|
|
92
|
+
): Promise<TestersCohortSnapshot[]> {
|
|
93
|
+
const rows = await db
|
|
94
|
+
.selectFrom(TESTERS_SNAPSHOTS_TABLE)
|
|
95
|
+
.selectAll()
|
|
96
|
+
.where("cohortId", "=", cohortId)
|
|
97
|
+
.orderBy("snapshotOn", "desc")
|
|
98
|
+
.limit(limit)
|
|
99
|
+
.execute();
|
|
100
|
+
return rows.map((row) => TestersCohortSnapshot.parse(row)).reverse();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** One snapshot by day, for the trend deltas. */
|
|
104
|
+
export async function snapshotOn(
|
|
105
|
+
db: TestersDatabase,
|
|
106
|
+
cohortId: string,
|
|
107
|
+
day: string,
|
|
108
|
+
): Promise<TestersCohortSnapshot | undefined> {
|
|
109
|
+
const row = await db
|
|
110
|
+
.selectFrom(TESTERS_SNAPSHOTS_TABLE)
|
|
111
|
+
.selectAll()
|
|
112
|
+
.where("cohortId", "=", cohortId)
|
|
113
|
+
.where("snapshotOn", "=", day)
|
|
114
|
+
.executeTakeFirst();
|
|
115
|
+
return row ? TestersCohortSnapshot.parse(row) : undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** How many snapshots a cohort has. Drives whether a trend can be stated at all. */
|
|
119
|
+
export async function countSnapshots(db: TestersDatabase, cohortId: string): Promise<number> {
|
|
120
|
+
const row = await db
|
|
121
|
+
.selectFrom(TESTERS_SNAPSHOTS_TABLE)
|
|
122
|
+
.select((eb) => eb.fn.countAll<number>().as("count"))
|
|
123
|
+
.where("cohortId", "=", cohortId)
|
|
124
|
+
.executeTakeFirst();
|
|
125
|
+
return Number(row?.count ?? 0);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Read one cohort in full: the roster, the replayed clock, and observed activity for every member.
|
|
130
|
+
*
|
|
131
|
+
* The activity lookup is batched across the whole roster rather than run per tester, because this runs
|
|
132
|
+
* inside a request as well as inside a Workflow, and a hundred-member cohort would otherwise be three
|
|
133
|
+
* hundred round trips before the first byte of a response.
|
|
134
|
+
*
|
|
135
|
+
* `log` is the caller's logger, passed straight through to {@link resolveActivity}. An unreadable
|
|
136
|
+
* activity read degrades the whole roster to "never signed in", which is indistinguishable from a
|
|
137
|
+
* genuinely inactive cohort — so the caller's logger is the only thing that says why. Defaults to the
|
|
138
|
+
* no-op so a caller with nothing to log through still reads.
|
|
139
|
+
*/
|
|
140
|
+
export async function readCohort(
|
|
141
|
+
db: TestersDatabase,
|
|
142
|
+
d1: D1Database,
|
|
143
|
+
cohort: TestersCohort,
|
|
144
|
+
config: TestersConfig,
|
|
145
|
+
now: Date,
|
|
146
|
+
log: Logger = noopLogger,
|
|
147
|
+
): Promise<CohortReading> {
|
|
148
|
+
const members = await listMembers(db, cohort.id);
|
|
149
|
+
const events = await listEvents(db, cohort.id);
|
|
150
|
+
|
|
151
|
+
const clock = readClock(
|
|
152
|
+
{
|
|
153
|
+
createdAt: cohort.createdAt,
|
|
154
|
+
targetSize: cohort.targetSize,
|
|
155
|
+
windowDays: cohort.windowDays,
|
|
156
|
+
resetPolicy: cohort.resetPolicy,
|
|
157
|
+
},
|
|
158
|
+
events,
|
|
159
|
+
now,
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const unreachable = new Set(members.filter((member) => member.unreachable).map((member) => member.email));
|
|
163
|
+
const activity = await resolveActivity(
|
|
164
|
+
d1,
|
|
165
|
+
members.map((member) => member.email),
|
|
166
|
+
{
|
|
167
|
+
since: new Date(now.getTime() - cohort.windowDays * MS_PER_DAY),
|
|
168
|
+
activeSince: new Date(now.getTime() - config.activeWithinDays * MS_PER_DAY),
|
|
169
|
+
unreachable,
|
|
170
|
+
},
|
|
171
|
+
log,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const readings: MemberReading[] = members.map((member) => ({
|
|
175
|
+
member,
|
|
176
|
+
activity:
|
|
177
|
+
activity.get(member.email) ??
|
|
178
|
+
({
|
|
179
|
+
email: member.email,
|
|
180
|
+
userId: null,
|
|
181
|
+
observability: member.unreachable ? "unreachable" : "unobservable",
|
|
182
|
+
state: member.unreachable ? "unreachable" : "never_linked",
|
|
183
|
+
lastAuthenticatedAt: null,
|
|
184
|
+
sessionsInWindow: 0,
|
|
185
|
+
devices: [],
|
|
186
|
+
} satisfies TesterActivity),
|
|
187
|
+
}));
|
|
188
|
+
|
|
189
|
+
return { cohort, clock, readings, events };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* How many observed testers first crossed into darkness in the last week.
|
|
194
|
+
*
|
|
195
|
+
* Feeds the trend sentence rather than the direction: "two testers went dark this week" is what a
|
|
196
|
+
* developer can act on, where "the forecast fell 0.07" is what a chart shows.
|
|
197
|
+
*/
|
|
198
|
+
export function newlyDark(readings: readonly MemberReading[], now: Date, threshold = 3): number {
|
|
199
|
+
return readings.filter((reading) => {
|
|
200
|
+
if (reading.activity.observability !== "observed" || !reading.activity.lastAuthenticatedAt) return false;
|
|
201
|
+
// The same opt-in floor the health score and the darkness histogram apply. This was the one
|
|
202
|
+
// darkness consumer left measuring from the raw last-authentication, so the trend sentence
|
|
203
|
+
// ("two testers went dark") could contradict the histogram on the very same snapshot row.
|
|
204
|
+
const dark = daysSince(lastSignOfLife(reading) ?? reading.activity.lastAuthenticatedAt, now);
|
|
205
|
+
return dark >= threshold && dark <= 7;
|
|
206
|
+
}).length;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The day key for `now`, so callers and the builder agree on which day they are writing. */
|
|
210
|
+
export function todayKey(now: Date): string {
|
|
211
|
+
return dayKey(now);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Resolve a cohort by id or by name.
|
|
216
|
+
*
|
|
217
|
+
* The CLI takes whichever the developer has to hand, and a name is what they actually remember. Ids
|
|
218
|
+
* are tried first: a cohort deliberately named after another's id is a contrived case, and preferring
|
|
219
|
+
* the id keeps the lookup unambiguous for a management client that only ever has one.
|
|
220
|
+
*/
|
|
221
|
+
export async function resolveCohortRef(db: TestersDatabase, ref: string): Promise<TestersCohort> {
|
|
222
|
+
const byId = await db.selectFrom(TESTERS_COHORTS_TABLE).selectAll().where("id", "=", ref).executeTakeFirst();
|
|
223
|
+
if (byId) return TestersCohort.parse(byId);
|
|
224
|
+
const byName = await db.selectFrom(TESTERS_COHORTS_TABLE).selectAll().where("name", "=", ref).executeTakeFirst();
|
|
225
|
+
if (byName) return TestersCohort.parse(byName);
|
|
226
|
+
throw new TestersCohortNotFoundError({ detail: `no cohort with id or name ${ref}` });
|
|
227
|
+
}
|