@pithy-sh/testers 0.1.0

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