@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,134 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The shared vocabulary of the roster: what state a tester is in, what moved them there, and how much
|
|
8
|
+
* of them we can actually see.
|
|
9
|
+
*
|
|
10
|
+
* **The naming rule this file exists to hold.** Anything derived from the opt-in event log is Pithy's
|
|
11
|
+
* estimate of a number Google owns and does not expose; anything derived from `@pithy-sh/auth` is
|
|
12
|
+
* observed fact. The two are never merged into a single "status", because a merged field would have to
|
|
13
|
+
* pick one authority and silently claim the other. `MemberState` is the estimate, `ActivityState` is the
|
|
14
|
+
* fact, and every member carries both.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A tester's position on the roster.
|
|
19
|
+
*
|
|
20
|
+
* **Only four things write a transition, and inactivity is not one of them.** A tester who opted in and
|
|
21
|
+
* never opens the app still counts toward Google's twelve, so lapsing them for going quiet would turn
|
|
22
|
+
* Pithy's count from "our record of who confirmed" into "our guess dressed as a record" — and the
|
|
23
|
+
* divergence from Google would become unbounded and undetectable. Silence makes a tester look alarming
|
|
24
|
+
* in the health column. It never moves them off the count.
|
|
25
|
+
*/
|
|
26
|
+
export const MemberState = z
|
|
27
|
+
.enum(["invited", "accepted", "opted_in", "lapsed", "removed"])
|
|
28
|
+
.describe(
|
|
29
|
+
"Roster state, replayed from the event log: `invited` (we sent the invitation), `accepted` (they answered it — they have agreed to test, so their address is ready to go on the store's tester list), `opted_in` (they followed the link through to the store's own opt-in page, which is the closest thing to enrollment Pithy can observe), `lapsed` (they opted out), `removed` (the developer took them off). Inactivity never writes any of these, and neither does signing in — that is activity, not membership.",
|
|
30
|
+
);
|
|
31
|
+
export type MemberState = z.output<typeof MemberState>;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What happened to a member, as an append-only fact.
|
|
35
|
+
*
|
|
36
|
+
* The streak is replayed from these rather than stored as a counter, so a correction is a recomputation
|
|
37
|
+
* and the history survives it. That is also what lets a snapshot written last Tuesday stay an accurate
|
|
38
|
+
* record of what we believed last Tuesday.
|
|
39
|
+
*/
|
|
40
|
+
export const MemberEventKind = z
|
|
41
|
+
.enum(["invited", "reinvited", "accepted", "opted_in", "lapsed", "removed", "nudged"])
|
|
42
|
+
.describe(
|
|
43
|
+
"The event kinds the roster is replayed from. `reinvited` and `nudged` are recorded but do not change state — they are the outreach history the cooldown and the health score read.",
|
|
44
|
+
);
|
|
45
|
+
export type MemberEventKind = z.output<typeof MemberEventKind>;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Who or what caused an event.
|
|
49
|
+
*
|
|
50
|
+
* Worth storing because the same transition means different things depending on its author: a `lapsed`
|
|
51
|
+
* the tester wrote by following an opt-out link is a fact about them, while a `lapsed` the developer
|
|
52
|
+
* wrote is a fact about the roster.
|
|
53
|
+
*/
|
|
54
|
+
export const EventActor = z
|
|
55
|
+
.enum(["tester", "developer", "system"])
|
|
56
|
+
.describe(
|
|
57
|
+
"Who caused this event: the `tester` (they followed a link), the `developer` (a control-plane or CLI action), or the `system` (the daily pass).",
|
|
58
|
+
);
|
|
59
|
+
export type EventActor = z.output<typeof EventActor>;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* How much of a tester we can see at all.
|
|
63
|
+
*
|
|
64
|
+
* This is a *different kind of statement* from a health score, which is why it gets its own field
|
|
65
|
+
* rather than a low number. A tester who opted in and never signed in is not evidence of risk; it is
|
|
66
|
+
* absence of evidence. Scoring them badly would paint a perfectly healthy cohort red the moment an
|
|
67
|
+
* adopter's test flow does not require a sign-in — and that is a common, legitimate app.
|
|
68
|
+
*/
|
|
69
|
+
export const Observability = z
|
|
70
|
+
.enum(["observed", "unobservable", "unreachable"])
|
|
71
|
+
.describe(
|
|
72
|
+
"Whether activity data exists for this tester: `observed` (their address matched a user with at least one session or device), `unobservable` (no match, or a match with no activity at all), `unreachable` (their address bounced or is suppressed, so we cannot even nudge them).",
|
|
73
|
+
);
|
|
74
|
+
export type Observability = z.output<typeof Observability>;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* What the observed activity says, when there is any.
|
|
78
|
+
*
|
|
79
|
+
* `never_linked` is deliberately not a flavour of `inactive`. "Quiet since the 12th" is a tester
|
|
80
|
+
* drifting away; "never signed in" may simply be an app that does not ask anyone to sign in. A UI that
|
|
81
|
+
* renders them the same way will send nudges to people who did nothing wrong.
|
|
82
|
+
*/
|
|
83
|
+
export const ActivityState = z
|
|
84
|
+
.enum(["active", "inactive", "never_linked", "unreachable"])
|
|
85
|
+
.describe(
|
|
86
|
+
"Observed activity: `active` (authenticated inside the active window), `inactive` (matched a user but has gone quiet, with a date), `never_linked` (the invited address never matched a user, so there is no 'since'), `unreachable` (bounced or suppressed).",
|
|
87
|
+
);
|
|
88
|
+
export type ActivityState = z.output<typeof ActivityState>;
|
|
89
|
+
|
|
90
|
+
/** The health bands a score falls into, each selecting one published survival prior. */
|
|
91
|
+
export const RiskBand = z
|
|
92
|
+
.enum(["healthy", "watch", "at_risk", "critical", "unknown"])
|
|
93
|
+
.describe(
|
|
94
|
+
"The band a tester's health score falls in, which selects their daily-survival prior. `unknown` is for a tester with no score at all — unobservable or unreachable — and must render gray rather than red.",
|
|
95
|
+
);
|
|
96
|
+
export type RiskBand = z.output<typeof RiskBand>;
|
|
97
|
+
|
|
98
|
+
/** Which nudge was sent. Each has shipped default copy and its own trigger in the daily pass. */
|
|
99
|
+
export const NudgeKind = z
|
|
100
|
+
.enum(["confirm", "store", "inactive", "closing"])
|
|
101
|
+
.describe(
|
|
102
|
+
"The nudge kinds: `confirm` (will you help test?), `store` (you are on the list — here is the link to join and install), `inactive` (you have not opened the app recently), `closing` (the test window is nearly over). `confirm` and `store` are two messages rather than one because the store's opt-in page only works once the developer has added that address to the tester list, which no API can do — sending the link first produces `App not available` and a tester who thinks the app is broken. Every kind ships default copy, so `pithy testers run` chases sensibly with no dashboard involved.",
|
|
103
|
+
);
|
|
104
|
+
export type NudgeKind = z.output<typeof NudgeKind>;
|
|
105
|
+
|
|
106
|
+
/** Which way a cohort is heading, over the last week of snapshots. */
|
|
107
|
+
export const TrendDirection = z
|
|
108
|
+
.enum(["improving", "steady", "declining", "unknown"])
|
|
109
|
+
.describe(
|
|
110
|
+
"Direction of travel across the trailing snapshots. `unknown` until three snapshots exist — a chart drawn from two points is a straight line through noise.",
|
|
111
|
+
);
|
|
112
|
+
export type TrendDirection = z.output<typeof TrendDirection>;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Why the projection reads the way it does — and, when a number is null, why there is no number.
|
|
116
|
+
*
|
|
117
|
+
* The null case is the one that matters. When nothing about a cohort is observable, the forecast is
|
|
118
|
+
* `null` and this says `no_observable_signal`, rather than a plausible-looking 0.5 that a dashboard
|
|
119
|
+
* would render as a real answer.
|
|
120
|
+
*/
|
|
121
|
+
export const ProjectionBasis = z
|
|
122
|
+
.enum(["estimated", "target_met", "insufficient_pipeline", "no_observable_signal", "no_history"])
|
|
123
|
+
.describe(
|
|
124
|
+
"Why the projection reads as it does: `estimated` (a normal forecast), `target_met` (already at target and holding), `insufficient_pipeline` (not enough pending invitations to close the gap at the observed conversion rate), `no_observable_signal` (nothing is observable, so the forecast is null rather than guessed), `no_history` (the cohort is too new to project).",
|
|
125
|
+
);
|
|
126
|
+
export type ProjectionBasis = z.output<typeof ProjectionBasis>;
|
|
127
|
+
|
|
128
|
+
/** How much to trust the forecast, driven by how much of the cohort we can see. */
|
|
129
|
+
export const ProjectionConfidence = z
|
|
130
|
+
.enum(["low", "moderate", "high"])
|
|
131
|
+
.describe(
|
|
132
|
+
"Confidence in the forecast, driven by observability coverage and the cohort's age. Null rather than `low` when coverage is zero — 'we can see nothing' is not a weak opinion, it is no opinion.",
|
|
133
|
+
);
|
|
134
|
+
export type ProjectionConfidence = z.output<typeof ProjectionConfidence>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { SQLiteDate, sqliteJson } from "@pithy-sh/core/src/data/codecs";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { EventActor, MemberEventKind, NudgeKind } from "./enums";
|
|
7
|
+
|
|
8
|
+
/** What a `nudged` event carries: which nudge went out, and the email job that carries it. */
|
|
9
|
+
export const NudgeEventMetadata = z
|
|
10
|
+
.object({
|
|
11
|
+
nudgeKind: NudgeKind.describe("Which nudge was sent."),
|
|
12
|
+
jobId: z
|
|
13
|
+
.string()
|
|
14
|
+
.describe(
|
|
15
|
+
"The `pithy_email_jobs.id` this nudge became. The join back to delivery, bounces, and the copy that was actually sent.",
|
|
16
|
+
),
|
|
17
|
+
copySource: z
|
|
18
|
+
.enum(["default", "supplied"])
|
|
19
|
+
.describe(
|
|
20
|
+
"Whether the words were this capability's shipped default or supplied by a control-plane caller. Recorded because a message sent over the adopter's own DKIM signature should be attributable to whoever wrote it.",
|
|
21
|
+
),
|
|
22
|
+
})
|
|
23
|
+
.describe("Metadata carried by a `nudged` event — which nudge, which email job, and who wrote the words.");
|
|
24
|
+
export type NudgeEventMetadata = z.output<typeof NudgeEventMetadata>;
|
|
25
|
+
|
|
26
|
+
/** Anything an event may carry beyond its kind. Empty for most transitions. */
|
|
27
|
+
export const EventMetadata = z
|
|
28
|
+
.object({
|
|
29
|
+
nudge: NudgeEventMetadata.optional().describe("Present on a `nudged` event; absent on every other kind."),
|
|
30
|
+
reason: z
|
|
31
|
+
.string()
|
|
32
|
+
.max(200)
|
|
33
|
+
.optional()
|
|
34
|
+
.describe("A short free-text reason a developer supplied when removing a tester. Never shown to the tester."),
|
|
35
|
+
})
|
|
36
|
+
.describe(
|
|
37
|
+
"Optional context on an event. Deliberately narrow — an event log that accepts arbitrary shapes stops being replayable.",
|
|
38
|
+
);
|
|
39
|
+
export type EventMetadata = z.output<typeof EventMetadata>;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One thing that happened to a tester — the append-only row in `pithy_testers_events`.
|
|
43
|
+
*
|
|
44
|
+
* **This table is the source of truth, and the streak is replayed from it rather than stored.** The
|
|
45
|
+
* design note in the issue asks for exactly this, and the reason is that a stored counter has no way to
|
|
46
|
+
* be wrong in public: if a member's opt-in date turns out to be mistaken, a counter can only be
|
|
47
|
+
* overwritten, losing both the old value and the fact that it changed. Replaying means a correction is
|
|
48
|
+
* an insert plus a recomputation, and every daily snapshot written before it stays a faithful record of
|
|
49
|
+
* what Pithy believed on that day — which is precisely what a trend chart is claiming to show.
|
|
50
|
+
*
|
|
51
|
+
* Rows are never updated and never deleted except by cohort teardown. `occurredAt` is the ordering key,
|
|
52
|
+
* not `id`: an event can be recorded late (an opt-in confirmed on a phone with a stale clock, a
|
|
53
|
+
* backfill) and replaying by insertion order would put it in the wrong day.
|
|
54
|
+
*/
|
|
55
|
+
export const TestersEvent = z
|
|
56
|
+
.object({
|
|
57
|
+
id: z
|
|
58
|
+
.number()
|
|
59
|
+
.int()
|
|
60
|
+
.describe("Primary key, auto-incrementing. Internal — an event id is never exposed or linked to."),
|
|
61
|
+
cohortId: z.string().describe("The cohort this event belongs to. Indexed with `occurredAt` for the replay scan."),
|
|
62
|
+
memberId: z
|
|
63
|
+
.string()
|
|
64
|
+
.describe("The member this event is about. Indexed — a per-tester replay reads only their rows."),
|
|
65
|
+
kind: MemberEventKind.describe("What happened."),
|
|
66
|
+
actor: EventActor.describe("Who caused it: the tester, the developer, or the daily pass."),
|
|
67
|
+
occurredAt: SQLiteDate.describe(
|
|
68
|
+
"When it happened — the replay's ordering key, not the insertion order. Ms-epoch in SQLite; a `Date` in app code.",
|
|
69
|
+
),
|
|
70
|
+
metadata: sqliteJson(EventMetadata).describe(
|
|
71
|
+
"Context for this event, Zod-validated on write and on read. `{}` for a plain transition.",
|
|
72
|
+
),
|
|
73
|
+
createdAt: SQLiteDate.describe(
|
|
74
|
+
"When the row was inserted. Differs from `occurredAt` for a backfilled event, which is how a late-recorded fact stays distinguishable from a timely one.",
|
|
75
|
+
),
|
|
76
|
+
})
|
|
77
|
+
.describe(
|
|
78
|
+
"One append-only fact about a tester in `pithy_testers_events` — the source of truth the roster state and the estimated streak are both replayed from.",
|
|
79
|
+
);
|
|
80
|
+
export type TestersEvent = z.output<typeof TestersEvent>;
|
|
81
|
+
export type TestersEventRow = z.input<typeof TestersEvent>;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { SQLiteBoolean, SQLiteDate } from "@pithy-sh/core/src/data/codecs";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { MemberState } from "./enums";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One tester on one cohort's roster — the row in `pithy_testers_members`.
|
|
10
|
+
*
|
|
11
|
+
* **The state and its dates are a projection of the event log, never the source of truth.** They live
|
|
12
|
+
* here so the common reads — "who is on this roster", "who is due a nudge" — are one indexed query
|
|
13
|
+
* instead of a replay, but every one of them is rebuilt from `pithy_testers_events` by
|
|
14
|
+
* `replayMember`. If the two ever disagree, the events win and the projection is wrong. That is the
|
|
15
|
+
* whole reason a correction to history is a recomputation rather than an argument.
|
|
16
|
+
*
|
|
17
|
+
* **The email address is the join key to `@pithy-sh/auth`, and it is stored lowercased.** A tester is
|
|
18
|
+
* invited by address and becomes visible only when they sign in with it; matching is exact, because
|
|
19
|
+
* auth's `email` column is unique and every Pithy sign-in is provider-verified. No `userId` is stored:
|
|
20
|
+
* caching it would create a second thing to keep in step, and the lookup is a single indexed hit.
|
|
21
|
+
*/
|
|
22
|
+
export const TestersMember = z
|
|
23
|
+
.object({
|
|
24
|
+
id: z
|
|
25
|
+
.string()
|
|
26
|
+
.describe(
|
|
27
|
+
"The member's UUID. Text rather than a sequential integer because it appears in control-plane responses and in the audit trail, and a countable id there tells a reader how large the roster is and lets them address rows they were never handed. The opt-in links carry `optInToken`, not this.",
|
|
28
|
+
),
|
|
29
|
+
cohortId: z.string().describe("The cohort this member belongs to. Indexed — every roster read starts here."),
|
|
30
|
+
email: z
|
|
31
|
+
.string()
|
|
32
|
+
.describe(
|
|
33
|
+
"The invited address, lowercased. The join key to `pithy_auth_users.email`, and the address every invitation and nudge is sent to. Unique per cohort.",
|
|
34
|
+
),
|
|
35
|
+
name: z.string().nullable().describe("A display name the developer supplied when inviting, or null."),
|
|
36
|
+
optInToken: z
|
|
37
|
+
.string()
|
|
38
|
+
.describe(
|
|
39
|
+
"The tester's confirmation credential — 32 bytes of CSPRNG, base64url. The token IS the credential, as with `@pithy-sh/storage`'s share links: it is looked up on every visit, which is what makes it revocable where a signed token could only expire. Generated when the row is created, so nothing has to be minted at send time and no secret is needed to build an invitation in any environment. Unique across the table.",
|
|
40
|
+
),
|
|
41
|
+
state: MemberState.describe(
|
|
42
|
+
"The member's current roster state, projected from the event log. Never written by inactivity — a quiet tester is still opted in.",
|
|
43
|
+
),
|
|
44
|
+
invitedAt: SQLiteDate.describe(
|
|
45
|
+
"When the first invitation was sent. Every member has one; it is how they got here.",
|
|
46
|
+
),
|
|
47
|
+
acceptedAt: SQLiteDate.nullable().describe(
|
|
48
|
+
"When they answered the first email saying they will test, or null. Consent, not enrollment: it is a tap on a link, needs no account, and proves nothing about installing or opting in at the store — that is `optedInAt`.",
|
|
49
|
+
),
|
|
50
|
+
optedInAt: SQLiteDate.nullable().describe(
|
|
51
|
+
"When they followed Pithy's confirmation link, or null. PITHY'S ESTIMATE of their opt-in date — it records that they clicked our link, not that Google recorded an opt-in. The two can differ and no API exposes Google's.",
|
|
52
|
+
),
|
|
53
|
+
lapsedAt: SQLiteDate.nullable().describe(
|
|
54
|
+
"When they opted out or were removed, or null. Written only by an explicit act — never by going quiet.",
|
|
55
|
+
),
|
|
56
|
+
lastInvitedAt: SQLiteDate.describe(
|
|
57
|
+
"When an invitation was most recently sent, including resends. Distinct from `invitedAt` so the streak still measures from the first contact.",
|
|
58
|
+
),
|
|
59
|
+
lastNudgedAt: SQLiteDate.nullable().describe(
|
|
60
|
+
"When a nudge was most recently enqueued for this tester, or null. The cooldown reads this, and it is written at enqueue rather than at delivery: a cooldown that waited for the send Workflow would let a retried request mail twice.",
|
|
61
|
+
),
|
|
62
|
+
nudgeCount: z
|
|
63
|
+
.number()
|
|
64
|
+
.int()
|
|
65
|
+
.nonnegative()
|
|
66
|
+
.describe(
|
|
67
|
+
"How many nudges have been enqueued since this tester last answered one. Cleared when they accept the invitation or confirm the opt-in, so it measures unanswered probes rather than lifetime volume. Feeds the health score's unanswered-probe penalty and the cap that stops chasing after three.",
|
|
68
|
+
),
|
|
69
|
+
unreachable: SQLiteBoolean.describe(
|
|
70
|
+
"Whether this address hard-bounced or is suppressed. SQLite has no boolean, so the column is `0`/`1` and the codec is the only place that conversion happens — it carried a hand-written integer and a rationale about summing it in SQL, and no SQL anywhere sums it: every count is a filter over already-decoded rows.",
|
|
71
|
+
),
|
|
72
|
+
createdAt: SQLiteDate.describe("When the member row was created."),
|
|
73
|
+
updatedAt: SQLiteDate.describe("When the member row was last written."),
|
|
74
|
+
})
|
|
75
|
+
.describe(
|
|
76
|
+
"One tester on one cohort in `pithy_testers_members` — a projection of the event log, kept for cheap reads. Every opt-in figure on it is Pithy's estimate; Google's equivalent is not readable.",
|
|
77
|
+
);
|
|
78
|
+
export type TestersMember = z.output<typeof TestersMember>;
|
|
79
|
+
export type TestersMemberRow = z.input<typeof TestersMember>;
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { SQLiteBoolean, SQLiteDate, sqliteJson } from "@pithy-sh/core/src/data/codecs";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { ProjectionBasis, ProjectionConfidence, TrendDirection } from "./enums";
|
|
7
|
+
|
|
8
|
+
/** A day-key: `YYYY-MM-DD`, UTC. */
|
|
9
|
+
const DAY_KEY = /^\d{4}-\d{2}-\d{2}$/;
|
|
10
|
+
|
|
11
|
+
/** How many nudges of each kind went out on one day. */
|
|
12
|
+
export const NudgeTally = z
|
|
13
|
+
.object({
|
|
14
|
+
confirm: z.number().int().describe("Will-you-test nudges enqueued on this day — the first email of the two."),
|
|
15
|
+
store: z
|
|
16
|
+
.number()
|
|
17
|
+
.int()
|
|
18
|
+
.describe(
|
|
19
|
+
"Store-link nudges enqueued on this day — the second email, and the only one that can move the opt-in count, because it is the one carrying the link that joins the test.",
|
|
20
|
+
),
|
|
21
|
+
inactive: z.number().int().describe("You-have-not-opened-the-app nudges enqueued on this day."),
|
|
22
|
+
closing: z.number().int().describe("The-window-is-closing nudges enqueued on this day."),
|
|
23
|
+
})
|
|
24
|
+
.describe("Nudges enqueued on this day, by kind, so send volume can be charted against the health series.");
|
|
25
|
+
export type NudgeTally = z.output<typeof NudgeTally>;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* One day of a cohort's position — the row in `pithy_testers_cohort_snapshots`, and the chartable
|
|
29
|
+
* record the whole dashboard trend is drawn from.
|
|
30
|
+
*
|
|
31
|
+
* **Why a snapshot table exists at all, when everything in it is derivable.** Two reasons, and the
|
|
32
|
+
* second is the load-bearing one. First, cost: a summary card should be one row, not a replay of every
|
|
33
|
+
* event plus a join against auth. Second, and more important, **activity is not derivable
|
|
34
|
+
* retroactively**. Sessions expire and rotate, devices are re-registered, and a tester who was quiet on
|
|
35
|
+
* the 9th but active on the 11th leaves no trace on the 12th that they were ever quiet. The opt-in
|
|
36
|
+
* clock can always be replayed; the early-warning signal cannot. If it is not written down on the day,
|
|
37
|
+
* it is gone — which is exactly why the darkness histogram, the thing that moves *before* the opt-in
|
|
38
|
+
* count does, has to be captured daily rather than computed on read.
|
|
39
|
+
*
|
|
40
|
+
* **Every field is one of two kinds and the names say which.** Anything prefixed `estimated` is Pithy's
|
|
41
|
+
* replay of its own invite records — its best guess at a figure Google computes and exposes through no
|
|
42
|
+
* API. Everything under the activity block is observed fact from `@pithy-sh/auth`. A field that blurred
|
|
43
|
+
* the two would be the single most damaging thing this package could ship, because a developer reading
|
|
44
|
+
* `heldDays: 13` and planning to apply for production access tomorrow is making a decision on it.
|
|
45
|
+
*
|
|
46
|
+
* The day key is UTC and stored as `YYYY-MM-DD` **text**, not a timestamp: day arithmetic and the
|
|
47
|
+
* unique index are then exact, with no timezone in the query. Google's own day boundary is undocumented
|
|
48
|
+
* and may not be ours, which is one more reason day fourteen is the wrong day to start trusting this.
|
|
49
|
+
*/
|
|
50
|
+
export const TestersCohortSnapshot = z
|
|
51
|
+
.object({
|
|
52
|
+
id: z
|
|
53
|
+
.number()
|
|
54
|
+
.int()
|
|
55
|
+
.describe("Primary key, auto-incrementing. Internal — a snapshot is addressed by cohort and day."),
|
|
56
|
+
cohortId: z.string().describe("The cohort this snapshot describes. Unique with `snapshotOn`."),
|
|
57
|
+
snapshotOn: z
|
|
58
|
+
.string()
|
|
59
|
+
.regex(DAY_KEY)
|
|
60
|
+
.describe("The UTC day this snapshot covers, `YYYY-MM-DD`. The chart's x-axis, and half of the row's identity."),
|
|
61
|
+
dayIndex: z
|
|
62
|
+
.number()
|
|
63
|
+
.int()
|
|
64
|
+
.describe("Days since the cohort was created — a zero-based x-axis, so two cohorts can be overlaid."),
|
|
65
|
+
computedAt: SQLiteDate.describe("When the daily pass computed this row."),
|
|
66
|
+
backfilled: SQLiteBoolean.describe(
|
|
67
|
+
"True when this row was written after its day had already passed — a missed run, or a replay. Charts must render a backfilled point dashed: the opt-in figures are still exact, but the activity figures were reconstructed from whatever survived, and are therefore a floor rather than a measurement.",
|
|
68
|
+
),
|
|
69
|
+
modelVersion: z
|
|
70
|
+
.string()
|
|
71
|
+
.describe(
|
|
72
|
+
"The survival and health constant set that produced this row's forecast. A trend line silently spanning two model versions is a lie; the chart annotates where it changes.",
|
|
73
|
+
),
|
|
74
|
+
|
|
75
|
+
// — roster composition: a stacked area falls out of these five —
|
|
76
|
+
rosterSize: z.number().int().describe("Members on the roster on this day, in every state."),
|
|
77
|
+
invitedCount: z.number().int().describe("Invited, and have not yet answered the first email."),
|
|
78
|
+
acceptedCount: z.number().int().describe("Agreed to test on this day, and waiting for the store link."),
|
|
79
|
+
estimatedOptedInCount: z
|
|
80
|
+
.number()
|
|
81
|
+
.int()
|
|
82
|
+
.describe(
|
|
83
|
+
"PITHY'S ESTIMATE of how many testers were opted in on this day, replayed from the confirmation-link records. Google's authoritative count is not readable by any API.",
|
|
84
|
+
),
|
|
85
|
+
lapsedCount: z.number().int().describe("Explicitly opted out or removed. Never written by inactivity."),
|
|
86
|
+
unreachableCount: z
|
|
87
|
+
.number()
|
|
88
|
+
.int()
|
|
89
|
+
.describe("Hard-bounced or suppressed in `@pithy-sh/email` — we cannot even nudge them."),
|
|
90
|
+
|
|
91
|
+
// — the clock, entirely Pithy's estimate —
|
|
92
|
+
targetSize: z
|
|
93
|
+
.number()
|
|
94
|
+
.int()
|
|
95
|
+
.describe("The target in force on this day, denormalized so a later change cannot rewrite history."),
|
|
96
|
+
windowDays: z.number().int().describe("The window in force on this day, denormalized for the same reason."),
|
|
97
|
+
meetsTarget: SQLiteBoolean.describe("Whether the estimated opted-in count reached the target on this day."),
|
|
98
|
+
headroom: z
|
|
99
|
+
.number()
|
|
100
|
+
.int()
|
|
101
|
+
.describe(
|
|
102
|
+
"Estimated opted-in count minus target. Charts as a signed series where zero is the danger line, and it can go negative.",
|
|
103
|
+
),
|
|
104
|
+
estimatedHeldDays: z
|
|
105
|
+
.number()
|
|
106
|
+
.int()
|
|
107
|
+
.describe(
|
|
108
|
+
"Pithy's estimate of the unbroken run of at-target days ending on this day. The number Play Console shows, as far as we can infer it.",
|
|
109
|
+
),
|
|
110
|
+
estimatedWindowStartOn: z
|
|
111
|
+
.string()
|
|
112
|
+
.regex(DAY_KEY)
|
|
113
|
+
.nullable()
|
|
114
|
+
.describe("The UTC day the current run began, or null while below target."),
|
|
115
|
+
estimatedDaysRemaining: z
|
|
116
|
+
.number()
|
|
117
|
+
.int()
|
|
118
|
+
.describe("Window days still to hold, on Pithy's estimate, floored at zero."),
|
|
119
|
+
resetCount: z
|
|
120
|
+
.number()
|
|
121
|
+
.int()
|
|
122
|
+
.describe(
|
|
123
|
+
"How many times the run has broken since the cohort started. Scar tissue, and it belongs on the chart.",
|
|
124
|
+
),
|
|
125
|
+
resetToday: SQLiteBoolean.describe(
|
|
126
|
+
"Whether the run broke on this day — the single most important annotation a cohort chart can carry.",
|
|
127
|
+
),
|
|
128
|
+
|
|
129
|
+
// — activity: observed fact from @pithy-sh/auth —
|
|
130
|
+
observedCount: z
|
|
131
|
+
.number()
|
|
132
|
+
.int()
|
|
133
|
+
.describe("Opted-in members whose address matched a user with at least one session or device."),
|
|
134
|
+
neverLinkedCount: z
|
|
135
|
+
.number()
|
|
136
|
+
.int()
|
|
137
|
+
.describe(
|
|
138
|
+
"Opted-in members whose invited address never matched a user. They still count toward the target — an app whose test flow needs no sign-in produces nothing else — and they must render differently from inactive.",
|
|
139
|
+
),
|
|
140
|
+
observedCoverage: z
|
|
141
|
+
.number()
|
|
142
|
+
.min(0)
|
|
143
|
+
.max(1)
|
|
144
|
+
.describe(
|
|
145
|
+
"Observed count over opted-in count, 0–1. The honest denominator behind every forecast number on this row.",
|
|
146
|
+
),
|
|
147
|
+
activeCount: z.number().int().describe("Observed members who authenticated inside the cohort's active window."),
|
|
148
|
+
darkThreeToSevenCount: z.number().int().describe("Observed members quiet 3–7 days. The first leading indicator."),
|
|
149
|
+
darkEightToThirteenCount: z
|
|
150
|
+
.number()
|
|
151
|
+
.int()
|
|
152
|
+
.describe("Observed members quiet 8–13 days. The strongest silent-uninstall signal."),
|
|
153
|
+
darkFourteenPlusCount: z.number().int().describe("Observed members quiet 14 days or more."),
|
|
154
|
+
sessionsInWindow: z.number().int().describe("Total sessions across the cohort inside the trailing window."),
|
|
155
|
+
targetPlatformDeviceCount: z
|
|
156
|
+
.number()
|
|
157
|
+
.int()
|
|
158
|
+
.describe("Members with at least one registered device on the cohort's target platform."),
|
|
159
|
+
|
|
160
|
+
// — health distribution —
|
|
161
|
+
healthyCount: z.number().int().describe("Observed members scoring 80–100."),
|
|
162
|
+
watchCount: z.number().int().describe("Observed members scoring 60–79."),
|
|
163
|
+
atRiskCount: z.number().int().describe("Observed members scoring 30–59."),
|
|
164
|
+
criticalCount: z.number().int().describe("Observed members scoring 0–29."),
|
|
165
|
+
unknownHealthCount: z.number().int().describe("Members with no score at all — unobservable or unreachable."),
|
|
166
|
+
medianHealth: z
|
|
167
|
+
.number()
|
|
168
|
+
.int()
|
|
169
|
+
.nullable()
|
|
170
|
+
.describe("Median health across observed members, or null when none are observed."),
|
|
171
|
+
minHealth: z
|
|
172
|
+
.number()
|
|
173
|
+
.int()
|
|
174
|
+
.nullable()
|
|
175
|
+
.describe(
|
|
176
|
+
"The weakest observed member's score, or null. A twelve-of-twelve cohort breaks at its weakest link, so the minimum matters more than the mean.",
|
|
177
|
+
),
|
|
178
|
+
|
|
179
|
+
// — the forecast, as of this day —
|
|
180
|
+
expectedSurvivors: z
|
|
181
|
+
.number()
|
|
182
|
+
.describe(
|
|
183
|
+
"Sum of each opted-in tester's chance of lasting the remaining days — 'we expect 11.3 of your 14 to still be in'.",
|
|
184
|
+
),
|
|
185
|
+
probabilityReachTarget: z.number().describe("Chance of reaching the target at all. One when already there."),
|
|
186
|
+
probabilityHoldWindow: z
|
|
187
|
+
.number()
|
|
188
|
+
.nullable()
|
|
189
|
+
.describe(
|
|
190
|
+
"Chance at least `targetSize` testers hold for the remaining days. Null when nothing about the cohort is observable.",
|
|
191
|
+
),
|
|
192
|
+
successProbability: z
|
|
193
|
+
.number()
|
|
194
|
+
.min(0)
|
|
195
|
+
.max(1)
|
|
196
|
+
.nullable()
|
|
197
|
+
.describe(
|
|
198
|
+
"PITHY'S ESTIMATE of completing the window: reach times hold. Capped below one — nothing is certain until Google says so, and Google is not talking. Null when there is no observable signal at all.",
|
|
199
|
+
),
|
|
200
|
+
successProbabilityLow: z
|
|
201
|
+
.number()
|
|
202
|
+
.min(0)
|
|
203
|
+
.max(1)
|
|
204
|
+
.nullable()
|
|
205
|
+
.describe("The pessimistic bound, treating every unobservable tester as fragile."),
|
|
206
|
+
successProbabilityHigh: z
|
|
207
|
+
.number()
|
|
208
|
+
.min(0)
|
|
209
|
+
.max(1)
|
|
210
|
+
.nullable()
|
|
211
|
+
.describe("The optimistic bound, treating every unobservable tester as solid."),
|
|
212
|
+
confidence: ProjectionConfidence.nullable().describe("How much to trust the forecast. Null when coverage is zero."),
|
|
213
|
+
basis: ProjectionBasis.describe("Why the forecast reads as it does, and why any null on this row is null."),
|
|
214
|
+
projectedTargetMetOn: z
|
|
215
|
+
.string()
|
|
216
|
+
.regex(DAY_KEY)
|
|
217
|
+
.nullable()
|
|
218
|
+
.describe("Projected UTC day the cohort first reaches target, or null with a basis."),
|
|
219
|
+
projectedCompleteOn: z
|
|
220
|
+
.string()
|
|
221
|
+
.regex(DAY_KEY)
|
|
222
|
+
.nullable()
|
|
223
|
+
.describe("Projected UTC day the window completes, or null with a basis."),
|
|
224
|
+
invitesNeeded: z
|
|
225
|
+
.number()
|
|
226
|
+
.int()
|
|
227
|
+
.describe(
|
|
228
|
+
"How many more people to invite to close the gap at the observed conversion rate. Zero when at target. Worth more to a developer than the probability next to it.",
|
|
229
|
+
),
|
|
230
|
+
recommendedRosterSize: z
|
|
231
|
+
.number()
|
|
232
|
+
.int()
|
|
233
|
+
.describe(
|
|
234
|
+
"The roster size that survives the window at this cohort's own conversion and drop-off rates. The over-provisioning answer.",
|
|
235
|
+
),
|
|
236
|
+
|
|
237
|
+
// — the trend, precomputed so one row renders a whole card —
|
|
238
|
+
optedInDelta1d: z
|
|
239
|
+
.number()
|
|
240
|
+
.int()
|
|
241
|
+
.nullable()
|
|
242
|
+
.describe("Change in the estimated opted-in count since yesterday. Null without a prior snapshot."),
|
|
243
|
+
optedInDelta7d: z
|
|
244
|
+
.number()
|
|
245
|
+
.int()
|
|
246
|
+
.nullable()
|
|
247
|
+
.describe("Change over seven days. Null without a snapshot seven days back."),
|
|
248
|
+
activeDelta7d: z
|
|
249
|
+
.number()
|
|
250
|
+
.int()
|
|
251
|
+
.nullable()
|
|
252
|
+
.describe("Change in the active count over seven days — engagement's direction."),
|
|
253
|
+
successProbabilityDelta1d: z.number().nullable().describe("Change in the forecast since yesterday."),
|
|
254
|
+
successProbabilityDelta7d: z
|
|
255
|
+
.number()
|
|
256
|
+
.nullable()
|
|
257
|
+
.describe("Change in the forecast over seven days. The trend rule's primary input."),
|
|
258
|
+
trendDirection: TrendDirection.describe(
|
|
259
|
+
"Improving, steady, declining, or unknown — by a published rule, not a fit.",
|
|
260
|
+
),
|
|
261
|
+
fragile: SQLiteBoolean.describe(
|
|
262
|
+
"At target, with zero headroom, and at least one at-risk or critical member: one lapse from a reset. Deliberately not a trend direction — a cohort can be improving and fragile at once, and collapsing them loses the more urgent of the two.",
|
|
263
|
+
),
|
|
264
|
+
trendReason: z
|
|
265
|
+
.string()
|
|
266
|
+
.describe(
|
|
267
|
+
"One brand-voice sentence explaining the direction, for rendering beside the arrow. E.g. `Two testers went dark this week.`",
|
|
268
|
+
),
|
|
269
|
+
|
|
270
|
+
// — operations —
|
|
271
|
+
nudgesSent: sqliteJson(NudgeTally).describe(
|
|
272
|
+
"Nudges enqueued on this day, by kind. Overlays the health series so an intervention can be read against its effect.",
|
|
273
|
+
),
|
|
274
|
+
bouncedCount: z.number().int().describe("Addresses newly found unreachable on this day."),
|
|
275
|
+
})
|
|
276
|
+
.describe(
|
|
277
|
+
"One day of a cohort's position in `pithy_testers_cohort_snapshots` — the chartable trend record the daily pass writes. Every opt-in figure here is Pithy's estimate from its own invite records; every activity figure is observed fact.",
|
|
278
|
+
);
|
|
279
|
+
export type TestersCohortSnapshot = z.output<typeof TestersCohortSnapshot>;
|
|
280
|
+
export type TestersCohortSnapshotRow = z.input<typeof TestersCohortSnapshot>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
5
|
+
import { createDatabase, type DatabaseSchema } from "@pithy-sh/core/src/data/db";
|
|
6
|
+
import type { Kysely } from "kysely";
|
|
7
|
+
import type { z } from "zod";
|
|
8
|
+
import { TestersCohort } from "./cohort";
|
|
9
|
+
import { TestersEvent } from "./event";
|
|
10
|
+
import { TestersMember } from "./member";
|
|
11
|
+
import { TestersCohortSnapshot } from "./snapshot";
|
|
12
|
+
|
|
13
|
+
/** The cohort itself. `CamelCasePlugin` snake-cases it to `pithy_testers_cohorts`. */
|
|
14
|
+
export const TESTERS_COHORTS_TABLE = "pithyTestersCohorts";
|
|
15
|
+
/** The roster projection. → `pithy_testers_members`. */
|
|
16
|
+
export const TESTERS_MEMBERS_TABLE = "pithyTestersMembers";
|
|
17
|
+
/** The append-only source of truth the streak is replayed from. → `pithy_testers_events`. */
|
|
18
|
+
export const TESTERS_EVENTS_TABLE = "pithyTestersEvents";
|
|
19
|
+
/** One row per cohort per UTC day — the chartable trend. → `pithy_testers_cohort_snapshots`. */
|
|
20
|
+
export const TESTERS_SNAPSHOTS_TABLE = "pithyTestersCohortSnapshots";
|
|
21
|
+
|
|
22
|
+
/** The testers tables map. All four are always present — none is behind a config flag. */
|
|
23
|
+
export function testersTables(): Record<string, z.ZodObject> {
|
|
24
|
+
return {
|
|
25
|
+
[TESTERS_COHORTS_TABLE]: TestersCohort,
|
|
26
|
+
[TESTERS_MEMBERS_TABLE]: TestersMember,
|
|
27
|
+
[TESTERS_EVENTS_TABLE]: TestersEvent,
|
|
28
|
+
[TESTERS_SNAPSHOTS_TABLE]: TestersCohortSnapshot,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The typed Kysely database over the testers tables. */
|
|
33
|
+
export type TestersTables = {
|
|
34
|
+
[TESTERS_COHORTS_TABLE]: typeof TestersCohort;
|
|
35
|
+
[TESTERS_MEMBERS_TABLE]: typeof TestersMember;
|
|
36
|
+
[TESTERS_EVENTS_TABLE]: typeof TestersEvent;
|
|
37
|
+
[TESTERS_SNAPSHOTS_TABLE]: typeof TestersCohortSnapshot;
|
|
38
|
+
};
|
|
39
|
+
export type TestersDatabase = Kysely<DatabaseSchema<TestersTables>>;
|
|
40
|
+
|
|
41
|
+
/** Build the testers database from a D1 binding, with `CamelCasePlugin` installed. */
|
|
42
|
+
export function testersDatabase(d1: D1Database): TestersDatabase {
|
|
43
|
+
return createDatabase(d1, {
|
|
44
|
+
[TESTERS_COHORTS_TABLE]: TestersCohort,
|
|
45
|
+
[TESTERS_MEMBERS_TABLE]: TestersMember,
|
|
46
|
+
[TESTERS_EVENTS_TABLE]: TestersEvent,
|
|
47
|
+
[TESTERS_SNAPSHOTS_TABLE]: TestersCohortSnapshot,
|
|
48
|
+
}) as unknown as TestersDatabase;
|
|
49
|
+
}
|