@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,511 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { normalizeAddress } from "@pithy-sh/core/src/address/address";
|
|
5
|
+
import { SQLiteBoolean } from "@pithy-sh/core/src/data/codecs";
|
|
6
|
+
import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
7
|
+
import { isStoreOptInUrl, type ResetPolicy, STORE_OPT_IN_HOSTS, type TesterPlatform } from "../config/config";
|
|
8
|
+
import { generateOptInToken } from "../crypto/token";
|
|
9
|
+
import { TestersCohort } from "../data/cohort";
|
|
10
|
+
import type { EventActor, MemberEventKind, MemberState, NudgeKind } from "../data/enums";
|
|
11
|
+
import { type EventMetadata, TestersEvent } from "../data/event";
|
|
12
|
+
import { TestersMember } from "../data/member";
|
|
13
|
+
import {
|
|
14
|
+
TESTERS_COHORTS_TABLE,
|
|
15
|
+
TESTERS_EVENTS_TABLE,
|
|
16
|
+
TESTERS_MEMBERS_TABLE,
|
|
17
|
+
type TestersDatabase,
|
|
18
|
+
} from "../data/tables";
|
|
19
|
+
import {
|
|
20
|
+
TestersAlreadyOnRosterError,
|
|
21
|
+
TestersCohortNotFoundError,
|
|
22
|
+
TestersMemberNotFoundError,
|
|
23
|
+
TestersRosterFullError,
|
|
24
|
+
TestersWithdrawnError,
|
|
25
|
+
} from "../error/errors";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Every write to the roster, and the one rule they all obey.
|
|
29
|
+
*
|
|
30
|
+
* **The event is written first, and the member row is a cache of it.** If the projection write fails
|
|
31
|
+
* after the event lands, the roster is stale but the truth is intact and a replay repairs it. The
|
|
32
|
+
* reverse ordering would lose the fact permanently while leaving a row that looks authoritative — and
|
|
33
|
+
* the fact in question is the opt-in date the whole streak is measured from. So: append, then project,
|
|
34
|
+
* always, and never the other way round.
|
|
35
|
+
*
|
|
36
|
+
* D1 has no interactive transactions through Kysely, so this is genuinely two statements rather than
|
|
37
|
+
* one. That is survivable precisely because the event log is the source of truth: a replay of
|
|
38
|
+
* `pithy_testers_events` reconstructs every member row from scratch, which makes the projection
|
|
39
|
+
* disposable by design rather than by luck.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/** The states a member may hold and still be considered on the roster. */
|
|
43
|
+
export const LIVE_STATES: readonly MemberState[] = ["invited", "accepted", "opted_in"];
|
|
44
|
+
|
|
45
|
+
/** What every write needs from its caller: the clock and an id source, both injected for determinism. */
|
|
46
|
+
export interface WriteDeps {
|
|
47
|
+
readonly db: TestersDatabase;
|
|
48
|
+
readonly now: Date;
|
|
49
|
+
readonly newId: () => string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Append one event. The source of truth; nothing else in this file runs before it. */
|
|
53
|
+
async function appendEvent(
|
|
54
|
+
deps: WriteDeps,
|
|
55
|
+
input: { cohortId: string; memberId: string; kind: MemberEventKind; actor: EventActor; metadata?: EventMetadata },
|
|
56
|
+
): Promise<void> {
|
|
57
|
+
const row = TestersEvent.encode({
|
|
58
|
+
// The id is assigned by SQLite; the encoded value is discarded below along with the rest of the
|
|
59
|
+
// generated column, and only exists because the schema's input side declares it.
|
|
60
|
+
id: 0,
|
|
61
|
+
cohortId: input.cohortId,
|
|
62
|
+
memberId: input.memberId,
|
|
63
|
+
kind: input.kind,
|
|
64
|
+
actor: input.actor,
|
|
65
|
+
occurredAt: deps.now,
|
|
66
|
+
metadata: input.metadata ?? {},
|
|
67
|
+
createdAt: deps.now,
|
|
68
|
+
});
|
|
69
|
+
const { id: _generated, ...insertable } = row;
|
|
70
|
+
await deps.db
|
|
71
|
+
.insertInto(TESTERS_EVENTS_TABLE)
|
|
72
|
+
// biome-ignore lint/suspicious/noExplicitAny: the row is the schema's z.input side; Kysely's insert type derives from it.
|
|
73
|
+
.values(insertable as any)
|
|
74
|
+
.execute();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Read one member, or raise the capability's own 404. */
|
|
78
|
+
export async function requireMember(db: TestersDatabase, memberId: string): Promise<TestersMember> {
|
|
79
|
+
const row = await db.selectFrom(TESTERS_MEMBERS_TABLE).selectAll().where("id", "=", memberId).executeTakeFirst();
|
|
80
|
+
if (!row) throw new TestersMemberNotFoundError({ detail: `no member ${memberId}` });
|
|
81
|
+
return TestersMember.parse(row);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Read one member of a cohort by address, or undefined. */
|
|
85
|
+
export async function findMemberByEmail(
|
|
86
|
+
db: TestersDatabase,
|
|
87
|
+
cohortId: string,
|
|
88
|
+
email: string,
|
|
89
|
+
): Promise<TestersMember | undefined> {
|
|
90
|
+
const row = await db
|
|
91
|
+
.selectFrom(TESTERS_MEMBERS_TABLE)
|
|
92
|
+
.selectAll()
|
|
93
|
+
.where("cohortId", "=", cohortId)
|
|
94
|
+
.where("email", "=", normalizeAddress(email))
|
|
95
|
+
.executeTakeFirst();
|
|
96
|
+
return row ? TestersMember.parse(row) : undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Patch a member row, always stamping `updatedAt`. */
|
|
100
|
+
async function patchMember(
|
|
101
|
+
deps: WriteDeps,
|
|
102
|
+
memberId: string,
|
|
103
|
+
patch: Partial<Record<string, string | number | null>>,
|
|
104
|
+
): Promise<void> {
|
|
105
|
+
await deps.db
|
|
106
|
+
.updateTable(TESTERS_MEMBERS_TABLE)
|
|
107
|
+
// biome-ignore lint/suspicious/noExplicitAny: the patch is a partial of the schema's z.input side.
|
|
108
|
+
.set({ ...patch, updatedAt: deps.now.getTime() } as any)
|
|
109
|
+
.where("id", "=", memberId)
|
|
110
|
+
.execute();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** What an invitation produced: the member, and whether this call created them. */
|
|
114
|
+
export interface InviteResult {
|
|
115
|
+
readonly member: TestersMember;
|
|
116
|
+
readonly created: boolean;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Invite an address onto a cohort.
|
|
121
|
+
*
|
|
122
|
+
* Refuses an address already on the roster in a live state rather than quietly resetting it. Swallowing
|
|
123
|
+
* that would be worse than it sounds: re-inviting an opted-in tester and overwriting `invitedAt` would
|
|
124
|
+
* silently move the date their streak is measured from, which is the one number the whole capability
|
|
125
|
+
* exists to get right. The caller either meant `resend` — which preserves the history — or is making a
|
|
126
|
+
* mistake worth being told about.
|
|
127
|
+
*
|
|
128
|
+
* A previously removed or lapsed member is revived instead, keeping their id so their event history
|
|
129
|
+
* stays attached to one person rather than fragmenting across two rows.
|
|
130
|
+
*/
|
|
131
|
+
export async function inviteMember(
|
|
132
|
+
deps: WriteDeps,
|
|
133
|
+
input: { cohortId: string; email: string; name?: string | null; maxRosterSize: number; actor?: EventActor },
|
|
134
|
+
): Promise<InviteResult> {
|
|
135
|
+
const email = normalizeAddress(input.email);
|
|
136
|
+
const existing = await findMemberByEmail(deps.db, input.cohortId, email);
|
|
137
|
+
|
|
138
|
+
if (existing && LIVE_STATES.includes(existing.state)) {
|
|
139
|
+
throw new TestersAlreadyOnRosterError({ detail: `${email} is already ${existing.state} on ${input.cohortId}` });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// A withdrawal is the tester's own decision, and it is durable. `lapsed` has exactly one producer —
|
|
143
|
+
// the opt-out route they followed themselves — so unlike `removed` it is never a state the developer
|
|
144
|
+
// chose and never one they may quietly reverse. Without this, a re-invite revived them to `invited`
|
|
145
|
+
// with a fresh token and a zeroed nudge count, and `POST /invite` sends by default: one call, and
|
|
146
|
+
// somebody who asked to be left alone is mailed again and re-enrolled in the daily chase. Every other
|
|
147
|
+
// send path already refused them; this was the one that did not, and it is the path a routine
|
|
148
|
+
// contact-list re-import takes.
|
|
149
|
+
if (existing && existing.state === "lapsed") {
|
|
150
|
+
throw new TestersWithdrawnError({
|
|
151
|
+
detail: `${email} withdrew from ${input.cohortId} on ${existing.lapsedAt?.toISOString() ?? "an unrecorded date"}`,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// The cap is checked for any invite that will produce a LIVE member — a revival included. Nesting it
|
|
156
|
+
// under `if (!existing)` let a cohort exceed its own cap: remove someone, invite a replacement to
|
|
157
|
+
// refill the cap, then re-invite the removed person and the check never ran. The existing row is by
|
|
158
|
+
// definition not live here, since the throw above guarantees it, so the same count is correct either
|
|
159
|
+
// way.
|
|
160
|
+
const live = await deps.db
|
|
161
|
+
.selectFrom(TESTERS_MEMBERS_TABLE)
|
|
162
|
+
.select((eb) => eb.fn.countAll<number>().as("count"))
|
|
163
|
+
.where("cohortId", "=", input.cohortId)
|
|
164
|
+
.where("state", "in", [...LIVE_STATES])
|
|
165
|
+
.executeTakeFirst();
|
|
166
|
+
// Refuse at the roster edge rather than at the store. A cohort that silently exceeded its cap would
|
|
167
|
+
// surface as a rejected email list days later, with no indication which invitation caused it.
|
|
168
|
+
if ((live?.count ?? 0) >= input.maxRosterSize) {
|
|
169
|
+
throw new TestersRosterFullError({
|
|
170
|
+
detail: `cohort ${input.cohortId} holds ${live?.count ?? 0} of ${input.maxRosterSize}`,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const memberId = existing?.id ?? deps.newId();
|
|
175
|
+
await appendEvent(deps, {
|
|
176
|
+
cohortId: input.cohortId,
|
|
177
|
+
memberId,
|
|
178
|
+
kind: "invited",
|
|
179
|
+
actor: input.actor ?? "developer",
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
if (existing) {
|
|
183
|
+
// A revival: the person is the same, so the id and the event history stay. Their clock restarts
|
|
184
|
+
// from this invitation, which is correct — their previous opt-in ended when they lapsed.
|
|
185
|
+
await patchMember(deps, memberId, {
|
|
186
|
+
state: "invited",
|
|
187
|
+
invitedAt: deps.now.getTime(),
|
|
188
|
+
lastInvitedAt: deps.now.getTime(),
|
|
189
|
+
acceptedAt: null,
|
|
190
|
+
optedInAt: null,
|
|
191
|
+
lapsedAt: null,
|
|
192
|
+
name: input.name ?? existing.name,
|
|
193
|
+
// The outreach history goes with the old membership. Left in place, `dueNudge`'s prompt-first
|
|
194
|
+
// rule never fires for a revived member — they wait two days for an invitation that was never
|
|
195
|
+
// sent to them — and the health score keeps deducting for probes sent before they left.
|
|
196
|
+
// The chase counter restarts, because `dueNudge`'s prompt-first rule keys on it: left in place, a
|
|
197
|
+
// revived member waits two days for an invitation that was never sent to them, and the health
|
|
198
|
+
// score keeps deducting for probes sent before they left.
|
|
199
|
+
nudgeCount: 0,
|
|
200
|
+
// `lastNudgedAt` does NOT reset. It is what the cooldown reads, and clearing it made a
|
|
201
|
+
// remove-then-invite cycle a way to mail immediately somebody who was inside the cooldown a
|
|
202
|
+
// moment ago — bypassing by the back door the guard that `resend` and `nudge` re-check by hand.
|
|
203
|
+
// A fresh token on revival. The old one went out in an email that may still be sitting in an
|
|
204
|
+
// inbox — or in a forwarded one — and a revived member should not be confirmable by a link issued
|
|
205
|
+
// for the membership that ended.
|
|
206
|
+
optInToken: generateOptInToken(),
|
|
207
|
+
});
|
|
208
|
+
return { member: await requireMember(deps.db, memberId), created: false };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const row = TestersMember.encode({
|
|
212
|
+
id: memberId,
|
|
213
|
+
cohortId: input.cohortId,
|
|
214
|
+
email,
|
|
215
|
+
name: input.name ?? null,
|
|
216
|
+
// Generated here, once, and never again. The invitation, every resend, and every confirm nudge all
|
|
217
|
+
// read the same value off the row, so nothing has to be minted at send time and the CLI can build a
|
|
218
|
+
// working invitation without reaching a secret.
|
|
219
|
+
optInToken: generateOptInToken(),
|
|
220
|
+
state: "invited",
|
|
221
|
+
invitedAt: deps.now,
|
|
222
|
+
acceptedAt: null,
|
|
223
|
+
optedInAt: null,
|
|
224
|
+
lapsedAt: null,
|
|
225
|
+
lastInvitedAt: deps.now,
|
|
226
|
+
lastNudgedAt: null,
|
|
227
|
+
nudgeCount: 0,
|
|
228
|
+
unreachable: false,
|
|
229
|
+
createdAt: deps.now,
|
|
230
|
+
updatedAt: deps.now,
|
|
231
|
+
});
|
|
232
|
+
await deps.db
|
|
233
|
+
.insertInto(TESTERS_MEMBERS_TABLE)
|
|
234
|
+
// biome-ignore lint/suspicious/noExplicitAny: the row is the schema's z.input side; Kysely's insert type derives from it.
|
|
235
|
+
.values(row as any)
|
|
236
|
+
.execute();
|
|
237
|
+
return { member: await requireMember(deps.db, memberId), created: true };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Send another invitation to someone already on the roster, preserving every date that matters. */
|
|
241
|
+
export async function resendInvite(deps: WriteDeps, memberId: string): Promise<TestersMember> {
|
|
242
|
+
const member = await requireMember(deps.db, memberId);
|
|
243
|
+
await appendEvent(deps, { cohortId: member.cohortId, memberId, kind: "reinvited", actor: "developer" });
|
|
244
|
+
// Only `lastInvitedAt` moves. `invitedAt` is the first contact and the anchor of every latency
|
|
245
|
+
// statistic the forecast computes; resetting it on a resend would make conversion look instant.
|
|
246
|
+
await patchMember(deps, memberId, { lastInvitedAt: deps.now.getTime() });
|
|
247
|
+
return requireMember(deps.db, memberId);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** What answering produced: the member, and whether this call was the one that recorded it. */
|
|
251
|
+
export interface AcceptResult {
|
|
252
|
+
readonly member: TestersMember;
|
|
253
|
+
/** False when they had already answered — a second click, a prefetching mail client, a forwarded link. */
|
|
254
|
+
readonly firstTime: boolean;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Record that a tester has agreed to test.
|
|
259
|
+
*
|
|
260
|
+
* This is their answer to the first email, and it is the developer's cue to add their address to the
|
|
261
|
+
* store's tester list — a manual step, because no API can add an address to a Play email list. Only
|
|
262
|
+
* once that is done does the store link work for them.
|
|
263
|
+
*
|
|
264
|
+
* **This is consent, not enrollment**, which is why it is a separate state from `opted_in`. Recording it
|
|
265
|
+
* as an opt-in would inflate the count with people who agreed and never joined, and the count is the
|
|
266
|
+
* one number the whole capability exists to keep honest.
|
|
267
|
+
*
|
|
268
|
+
* Idempotent, and one-way past `accepted`: a tester who has already reached the store does not go
|
|
269
|
+
* backwards because they clicked the older email again.
|
|
270
|
+
*/
|
|
271
|
+
export async function recordAccepted(deps: WriteDeps, memberId: string): Promise<AcceptResult> {
|
|
272
|
+
const member = await requireMember(deps.db, memberId);
|
|
273
|
+
if (member.state !== "invited") return { member, firstTime: false };
|
|
274
|
+
await appendEvent(deps, { cohortId: member.cohortId, memberId, kind: "accepted", actor: "tester" });
|
|
275
|
+
// The counter resets because they answered. It feeds the health score's "unanswered probes" penalty,
|
|
276
|
+
// and a lifetime total would keep deducting for messages the tester demonstrably *did* answer —
|
|
277
|
+
// dropping a perfectly engaged tester two health bands for the crime of having been chased once.
|
|
278
|
+
await patchMember(deps, memberId, { state: "accepted", acceptedAt: deps.now.getTime(), nudgeCount: 0 });
|
|
279
|
+
return { member: await requireMember(deps.db, memberId), firstTime: true };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** What confirming produced: the member, and whether this call was the one that confirmed them. */
|
|
283
|
+
export interface OptInResult {
|
|
284
|
+
readonly member: TestersMember;
|
|
285
|
+
/** False when they had already confirmed — a second click, a prefetching mail client, a forwarded link. */
|
|
286
|
+
readonly firstTime: boolean;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Record that a tester followed the link through to the store's own opt-in page.
|
|
291
|
+
*
|
|
292
|
+
* **This is the strongest evidence of enrollment Pithy can have, and it is still not proof.** We know
|
|
293
|
+
* they reached Google's page; whether they accepted there, with the right account, is Google's to know
|
|
294
|
+
* and no API reports it. That gap is exactly what `estimated*` names and the disclaimer states.
|
|
295
|
+
*
|
|
296
|
+
* **Idempotent, and that is a requirement rather than a nicety.** The link is followed from an email
|
|
297
|
+
* client, and email clients prefetch links, scanners follow them, and people click twice when a page is
|
|
298
|
+
* slow. If a second visit re-stamped `optedInAt`, every one of those would silently reset the tester's
|
|
299
|
+
* streak to zero — turning the most ordinary user behavior there is into the exact failure this
|
|
300
|
+
* capability exists to prevent. So a repeat visit returns the original date and writes nothing.
|
|
301
|
+
*/
|
|
302
|
+
export async function confirmOptIn(deps: WriteDeps, memberId: string): Promise<OptInResult> {
|
|
303
|
+
const member = await requireMember(deps.db, memberId);
|
|
304
|
+
if (member.state === "opted_in") return { member, firstTime: false };
|
|
305
|
+
|
|
306
|
+
await appendEvent(deps, { cohortId: member.cohortId, memberId, kind: "opted_in", actor: "tester" });
|
|
307
|
+
await patchMember(deps, memberId, {
|
|
308
|
+
state: "opted_in",
|
|
309
|
+
optedInAt: deps.now.getTime(),
|
|
310
|
+
lapsedAt: null,
|
|
311
|
+
// Answered, so the unanswered-probe count starts again. See `recordAccepted` for why a lifetime
|
|
312
|
+
// total would be the wrong input to a penalty whose field is named for what went *unanswered*.
|
|
313
|
+
nudgeCount: 0,
|
|
314
|
+
// Someone confirming has demonstrably received mail at this address, so any earlier bounce is
|
|
315
|
+
// stale. Leaving it set would exclude them from every future nudge for a delivery failure that has
|
|
316
|
+
// visibly stopped being true.
|
|
317
|
+
unreachable: 0,
|
|
318
|
+
});
|
|
319
|
+
return { member: await requireMember(deps.db, memberId), firstTime: true };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Record a tester's own opt-out. Idempotent for the same reason the confirmation is. */
|
|
323
|
+
export async function lapseMember(deps: WriteDeps, memberId: string): Promise<LapseResult> {
|
|
324
|
+
const member = await requireMember(deps.db, memberId);
|
|
325
|
+
if (member.state === "lapsed" || member.state === "removed") return { member, firstTime: false };
|
|
326
|
+
await appendEvent(deps, { cohortId: member.cohortId, memberId, kind: "lapsed", actor: "tester" });
|
|
327
|
+
// Rotating the token is what makes an opt-out stick. Without it the store link already sitting in the
|
|
328
|
+
// tester's inbox stays live, and one replay — a mail client prefetching an older message, a forwarded
|
|
329
|
+
// thread — silently re-enrolls someone who explicitly withdrew.
|
|
330
|
+
await patchMember(deps, memberId, {
|
|
331
|
+
state: "lapsed",
|
|
332
|
+
lapsedAt: deps.now.getTime(),
|
|
333
|
+
optInToken: generateOptInToken(),
|
|
334
|
+
});
|
|
335
|
+
return { member: await requireMember(deps.db, memberId), firstTime: true };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** What withdrawing produced: the member, and whether this call was the one that withdrew them. */
|
|
339
|
+
export interface LapseResult {
|
|
340
|
+
readonly member: TestersMember;
|
|
341
|
+
/** False when they had already withdrawn — an unauthenticated replay of a link must not write again. */
|
|
342
|
+
readonly firstTime: boolean;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Take a tester off the roster. The developer's act, recorded as theirs. */
|
|
346
|
+
export async function removeMember(deps: WriteDeps, memberId: string, reason?: string): Promise<TestersMember> {
|
|
347
|
+
const member = await requireMember(deps.db, memberId);
|
|
348
|
+
if (member.state === "removed") return member;
|
|
349
|
+
await appendEvent(deps, {
|
|
350
|
+
cohortId: member.cohortId,
|
|
351
|
+
memberId,
|
|
352
|
+
kind: "removed",
|
|
353
|
+
actor: "developer",
|
|
354
|
+
metadata: reason ? { reason } : {},
|
|
355
|
+
});
|
|
356
|
+
// Rotating the token is what makes removal a revocation rather than a label. A signed link could only
|
|
357
|
+
// have expired; this one stops working on the next request, which is the point of holding it in a row.
|
|
358
|
+
await patchMember(deps, memberId, {
|
|
359
|
+
state: "removed",
|
|
360
|
+
lapsedAt: deps.now.getTime(),
|
|
361
|
+
optInToken: generateOptInToken(),
|
|
362
|
+
});
|
|
363
|
+
return requireMember(deps.db, memberId);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Record that a nudge was enqueued.
|
|
368
|
+
*
|
|
369
|
+
* **Written at enqueue, not at delivery.** The cooldown reads `lastNudgedAt`, and a cooldown that
|
|
370
|
+
* waited for the send Workflow to confirm delivery would leave a window in which a retried request
|
|
371
|
+
* mails the same tester twice — which is precisely the failure the cooldown exists to prevent. An email
|
|
372
|
+
* enqueued and then failing to send is a delivery problem; an email sent twice is a lost tester.
|
|
373
|
+
*/
|
|
374
|
+
export async function recordNudge(
|
|
375
|
+
deps: WriteDeps,
|
|
376
|
+
input: { memberId: string; nudgeKind: NudgeKind; jobId: string; copySource: "default" | "supplied" },
|
|
377
|
+
): Promise<void> {
|
|
378
|
+
const member = await requireMember(deps.db, input.memberId);
|
|
379
|
+
await appendEvent(deps, {
|
|
380
|
+
cohortId: member.cohortId,
|
|
381
|
+
memberId: input.memberId,
|
|
382
|
+
kind: "nudged",
|
|
383
|
+
actor: "system",
|
|
384
|
+
metadata: { nudge: { nudgeKind: input.nudgeKind, jobId: input.jobId, copySource: input.copySource } },
|
|
385
|
+
});
|
|
386
|
+
await patchMember(deps, input.memberId, {
|
|
387
|
+
lastNudgedAt: deps.now.getTime(),
|
|
388
|
+
nudgeCount: member.nudgeCount + 1,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Mark an address unreachable after a bounce or a suppression. No event: delivery is not roster state. */
|
|
393
|
+
export async function markUnreachable(deps: WriteDeps, memberId: string, unreachable: boolean): Promise<void> {
|
|
394
|
+
// `patchMember` writes raw column values, so the conversion happens through the codec rather than by
|
|
395
|
+
// hand — the one place `true` becomes `1` for this field. The narrowing is only to Zod's declared
|
|
396
|
+
// input union, which is wide because the decode side accepts `0 | 1 | boolean | string`; the encode
|
|
397
|
+
// side returns `0 | 1` and nothing else.
|
|
398
|
+
const stored = SQLiteBoolean.encode(unreachable) as 0 | 1;
|
|
399
|
+
await patchMember(deps, memberId, { unreachable: stored });
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** What creating a cohort needs. Every field explicit — the caller resolves the config defaults. */
|
|
403
|
+
export interface CreateCohortInput {
|
|
404
|
+
readonly name: string;
|
|
405
|
+
readonly targetSize: number;
|
|
406
|
+
readonly windowDays: number;
|
|
407
|
+
readonly maxRosterSize: number;
|
|
408
|
+
readonly targetPlatform: TesterPlatform;
|
|
409
|
+
readonly resetPolicy: ResetPolicy;
|
|
410
|
+
/** The store's own opt-in page, where a tester actually enrolls. Null until the developer supplies it. */
|
|
411
|
+
readonly storeOptInUrl: string | null;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Create a cohort.
|
|
416
|
+
*
|
|
417
|
+
* The target, window, cap, and reset policy are written onto the row rather than read from config at
|
|
418
|
+
* query time. A cohort runs for a fortnight while config is redeployed for unrelated reasons, and if
|
|
419
|
+
* the clock read its rules live, raising the target from twelve to fifteen would retroactively rewrite
|
|
420
|
+
* whether last Tuesday counted — and the trend chart would change shape behind the developer with
|
|
421
|
+
* nothing to explain it.
|
|
422
|
+
*/
|
|
423
|
+
export async function createCohort(deps: WriteDeps, input: CreateCohortInput): Promise<TestersCohort> {
|
|
424
|
+
// Checked here rather than only on the config default, because `--store-url` is the only way to set
|
|
425
|
+
// this per cohort and it reached the column unvalidated — a bare `z.string().nullable()` over a plain
|
|
426
|
+
// `text` column. A value that is present but not usable is worse than an absent one: the daily pass
|
|
427
|
+
// reads presence as readiness and mails every accepted tester a link that enrolls nobody.
|
|
428
|
+
if (input.storeOptInUrl !== null && !isStoreOptInUrl(input.storeOptInUrl)) {
|
|
429
|
+
throw new ValidationError({
|
|
430
|
+
message: "That store opt-in link is not a store link.",
|
|
431
|
+
action: `Copy it from the console. It must be an https URL on ${STORE_OPT_IN_HOSTS.join(" or ")}.`,
|
|
432
|
+
detail: `${input.storeOptInUrl} is not an https URL on a supported store host`,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// The same invariant the migration enforces with a CHECK and the config layer enforces with a
|
|
437
|
+
// sentence. Without it here, `--max-roster 5` against the default target of twelve — a natural thing
|
|
438
|
+
// to type — surfaced as `CHECK constraint failed` out of Kysely: not a `PithyError`, so no action
|
|
439
|
+
// line, and no `--json` error object for an agent driving the CLI.
|
|
440
|
+
if (input.maxRosterSize < input.targetSize) {
|
|
441
|
+
throw new ValidationError({
|
|
442
|
+
message: `A target of ${input.targetSize} cannot fit in a roster capped at ${input.maxRosterSize}.`,
|
|
443
|
+
action: "Raise the cap, or lower the target. The cap must be at least the target.",
|
|
444
|
+
detail: `targetSize ${input.targetSize} exceeds maxRosterSize ${input.maxRosterSize}`,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Names are unique by constraint, so a repeat surfaces as `UNIQUE constraint failed` out of Kysely —
|
|
449
|
+
// not a `PithyError`, so no action line and, under `--json`, no error object for an agent to read.
|
|
450
|
+
// Checked here rather than caught below because the constraint tells us nothing about which name.
|
|
451
|
+
const clash = await deps.db
|
|
452
|
+
.selectFrom(TESTERS_COHORTS_TABLE)
|
|
453
|
+
.select(["id"])
|
|
454
|
+
.where("name", "=", input.name)
|
|
455
|
+
.executeTakeFirst();
|
|
456
|
+
if (clash) {
|
|
457
|
+
throw new ValidationError({
|
|
458
|
+
message: `There is already a cohort called ${input.name}.`,
|
|
459
|
+
action: "Pick another name, or use the existing cohort.",
|
|
460
|
+
detail: `cohort name ${input.name} is taken by ${String(clash.id)}`,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const id = deps.newId();
|
|
465
|
+
const row = TestersCohort.encode({
|
|
466
|
+
id,
|
|
467
|
+
name: input.name,
|
|
468
|
+
targetPlatform: input.targetPlatform,
|
|
469
|
+
targetSize: input.targetSize,
|
|
470
|
+
windowDays: input.windowDays,
|
|
471
|
+
maxRosterSize: input.maxRosterSize,
|
|
472
|
+
storeOptInUrl: input.storeOptInUrl,
|
|
473
|
+
resetPolicy: input.resetPolicy,
|
|
474
|
+
closedAt: null,
|
|
475
|
+
createdAt: deps.now,
|
|
476
|
+
updatedAt: deps.now,
|
|
477
|
+
});
|
|
478
|
+
await deps.db
|
|
479
|
+
.insertInto(TESTERS_COHORTS_TABLE)
|
|
480
|
+
// biome-ignore lint/suspicious/noExplicitAny: the row is the schema's z.input side; Kysely's insert type derives from it.
|
|
481
|
+
.values(row as any)
|
|
482
|
+
.execute();
|
|
483
|
+
const created = await deps.db.selectFrom(TESTERS_COHORTS_TABLE).selectAll().where("id", "=", id).executeTakeFirst();
|
|
484
|
+
if (!created) throw new TestersCohortNotFoundError({ detail: `cohort ${id} vanished immediately after insert` });
|
|
485
|
+
return TestersCohort.parse(created);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Close a cohort. It keeps its history and accrues no further snapshots or nudges. */
|
|
489
|
+
export async function closeCohort(deps: WriteDeps, cohortId: string): Promise<TestersCohort> {
|
|
490
|
+
await deps.db
|
|
491
|
+
.updateTable(TESTERS_COHORTS_TABLE)
|
|
492
|
+
// biome-ignore lint/suspicious/noExplicitAny: partial of the schema's z.input side.
|
|
493
|
+
.set({ closedAt: deps.now.getTime(), updatedAt: deps.now.getTime() } as any)
|
|
494
|
+
.where("id", "=", cohortId)
|
|
495
|
+
.execute();
|
|
496
|
+
const row = await deps.db.selectFrom(TESTERS_COHORTS_TABLE).selectAll().where("id", "=", cohortId).executeTakeFirst();
|
|
497
|
+
if (!row) throw new TestersCohortNotFoundError({ detail: `no cohort ${cohortId}` });
|
|
498
|
+
return TestersCohort.parse(row);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Find the member a confirmation token belongs to.
|
|
503
|
+
*
|
|
504
|
+
* Returns `undefined` rather than throwing, so the route can answer an unknown token with the same
|
|
505
|
+
* words it answers an expired or removed one — a differentiated response here would turn the public
|
|
506
|
+
* route into an oracle for which testers exist.
|
|
507
|
+
*/
|
|
508
|
+
export async function findMemberByToken(db: TestersDatabase, token: string): Promise<TestersMember | undefined> {
|
|
509
|
+
const row = await db.selectFrom(TESTERS_MEMBERS_TABLE).selectAll().where("optInToken", "=", token).executeTakeFirst();
|
|
510
|
+
return row ? TestersMember.parse(row) : undefined;
|
|
511
|
+
}
|