fix(waitlist): queue + retry failed signups instead of silently opening mailto (#165)
A signup that hit a network error, the 8s timeout or a 5xx was handed straight
to a `mailto:` composer. That path is lossy by design: it only works if the user
actually presses send, and if we keep reconciling the support inbox into Brevo
list 4 forever (AGE-61's hourly job). 20 of 21 signups were lost that way before
that reconciler existed, and Play's active base is ~100% on v0.4.10+ — so this
was current builds leaking, not just the ~436 stale sideloads.
Now:
- Failed-but-retryable signups are persisted on-device
(`opencode.waitlist.pending.v1`, AsyncStorage) and retried on every app
foreground (`app/_layout.tsx`) and on the Add Connection screen mount.
- 4xx stays non-retryable: the server will never accept that address, so we ask
the user to fix it instead of queueing garbage forever.
- `mailto:` is now only ever opened by an explicit user tap ("Still not working?
Email us instead"), shown after 3 failed attempts, or offered in an alert when
device storage itself refuses the write — never as the silent default.
- The UI tells the truth: "Saved on this device — we'll finish signing you up as
soon as you're back online" instead of implying it was sent.
- `WaitlistResult.fallback` -> `retryable`, `shouldFallbackToMailto` ->
`isRetryableFailure`: the decision is about retry, not about mail.
Queue policy: dedupe by email, cap 5 entries, 30-day TTL, corrupt/foreign JSON
is discarded rather than replayed. Storage and the clock are injected so the
whole thing runs under `node --test` (16 new tests, incl. the acceptance case:
offline signup -> queued -> reconnect -> reaches the server, no mail client).
Also commits the AGE-61 measurement artifacts that were only ever local
(`distribution/waitlist-signup-path-coverage.md`, `scripts/play-version-share.mjs`)
and updates the doc's "current builds still leak" section, which this fixes.
Refs AGE-87, AGE-61.
Co-authored-by: engineer <engineer@macbookpro.lan>
This commit is contained in:
@@ -19,6 +19,7 @@ import * as notifications from "../src/lib/notifications"
|
|||||||
import { addBreadcrumb, wrap } from "../src/lib/sentry"
|
import { addBreadcrumb, wrap } from "../src/lib/sentry"
|
||||||
import { loadTelemetryConsent, setTelemetryConsent } from "../src/lib/telemetry"
|
import { loadTelemetryConsent, setTelemetryConsent } from "../src/lib/telemetry"
|
||||||
import { initAnalytics, trackAppOpened } from "../src/lib/analytics"
|
import { initAnalytics, trackAppOpened } from "../src/lib/analytics"
|
||||||
|
import { flushPendingSignups } from "../src/lib/waitlist-queue-storage"
|
||||||
|
|
||||||
const queryClient = new QueryClient()
|
const queryClient = new QueryClient()
|
||||||
|
|
||||||
@@ -95,6 +96,30 @@ function RootLayout() {
|
|||||||
return () => sub.remove()
|
return () => sub.remove()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Retry any waitlist signup that couldn't reach the server when the user
|
||||||
|
// tapped Join (AGE-87). Runs at cold start and on every foreground, which is
|
||||||
|
// the cheapest reliable proxy for "connectivity may have come back" — it is a
|
||||||
|
// no-op (single storage read, no network) when the queue is empty, and it
|
||||||
|
// replaces the old silent mailto: fallback that lost 20 of 21 signups.
|
||||||
|
useEffect(() => {
|
||||||
|
const flush = () => {
|
||||||
|
void flushPendingSignups()
|
||||||
|
.then((outcome) => {
|
||||||
|
if (outcome.synced.length > 0) {
|
||||||
|
addBreadcrumb({ category: "waitlist", message: `retried ${outcome.synced.length} queued signup(s)` })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Best effort: the entry stays queued for the next foreground.
|
||||||
|
})
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
const sub = AppState.addEventListener("change", (next) => {
|
||||||
|
if (next === "active") flush()
|
||||||
|
})
|
||||||
|
return () => sub.remove()
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Connect/disconnect SSE and load catalog when client changes
|
// Connect/disconnect SSE and load catalog when client changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (client && !sseStarted.current) {
|
if (client && !sseStarted.current) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@@ -21,7 +21,8 @@ import { captureDiagnostic } from "../../src/lib/sentry"
|
|||||||
import { parseUrl } from "../../src/lib/diagnostics-classify"
|
import { parseUrl } from "../../src/lib/diagnostics-classify"
|
||||||
import { buildAuth } from "../../src/lib/auth"
|
import { buildAuth } from "../../src/lib/auth"
|
||||||
import { AnalyticsEvent, track } from "../../src/lib/analytics"
|
import { AnalyticsEvent, track } from "../../src/lib/analytics"
|
||||||
import { submitWaitlistSignup, buildWaitlistMailtoUrl } from "../../src/lib/waitlist"
|
import { submitWaitlistSignup, buildWaitlistMailtoUrl, needsManualEscapeHatch, type QueuedSignup } from "../../src/lib/waitlist"
|
||||||
|
import { flushPendingSignups, queuePendingSignup, readPendingSignups, dropPendingSignup } from "../../src/lib/waitlist-queue-storage"
|
||||||
|
|
||||||
export default function AddConnectionScreen() {
|
export default function AddConnectionScreen() {
|
||||||
const colorScheme = useColorScheme()
|
const colorScheme = useColorScheme()
|
||||||
@@ -41,7 +42,34 @@ export default function AddConnectionScreen() {
|
|||||||
const [password, setPassword] = useState("")
|
const [password, setPassword] = useState("")
|
||||||
const [isConnecting, setIsConnecting] = useState(false)
|
const [isConnecting, setIsConnecting] = useState(false)
|
||||||
const [waitlistEmail, setWaitlistEmail] = useState("")
|
const [waitlistEmail, setWaitlistEmail] = useState("")
|
||||||
const [waitlistState, setWaitlistState] = useState<"idle" | "submitting" | "joined">("idle")
|
// "queued" = the POST failed but the signup is persisted on-device and will
|
||||||
|
// be retried on the next foreground/connectivity (AGE-87). It is NOT "sent".
|
||||||
|
const [waitlistState, setWaitlistState] = useState<"idle" | "submitting" | "joined" | "queued">("idle")
|
||||||
|
const [pendingSignup, setPendingSignup] = useState<QueuedSignup | null>(null)
|
||||||
|
|
||||||
|
// Retry anything left over from a previous session as soon as this screen
|
||||||
|
// opens (the root layout also flushes on every foreground), then reflect the
|
||||||
|
// real state back to the user instead of pretending nothing is pending.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
void (async () => {
|
||||||
|
const outcome = await flushPendingSignups().catch(() => null)
|
||||||
|
if (cancelled) return
|
||||||
|
const pending = outcome ? outcome.pending : await readPendingSignups().catch(() => [])
|
||||||
|
if (cancelled) return
|
||||||
|
if (outcome && outcome.synced.length > 0 && pending.length === 0) {
|
||||||
|
setWaitlistState("joined")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (pending.length > 0) {
|
||||||
|
setPendingSignup(pending[pending.length - 1])
|
||||||
|
setWaitlistState((current) => (current === "idle" ? "queued" : current))
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const buildUrl = () => {
|
const buildUrl = () => {
|
||||||
if (mode === "advanced") return url.trim()
|
if (mode === "advanced") return url.trim()
|
||||||
@@ -213,25 +241,70 @@ export default function AddConnectionScreen() {
|
|||||||
|
|
||||||
const handleJoinWaitlist = async () => {
|
const handleJoinWaitlist = async () => {
|
||||||
if (waitlistState === "submitting") return
|
if (waitlistState === "submitting") return
|
||||||
|
const attemptedEmail = waitlistEmail
|
||||||
setWaitlistState("submitting")
|
setWaitlistState("submitting")
|
||||||
const result = await submitWaitlistSignup(waitlistEmail)
|
const result = await submitWaitlistSignup(attemptedEmail)
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
|
// Clear any earlier queued attempt for the same address so the flush
|
||||||
|
// doesn't re-post it.
|
||||||
|
void dropPendingSignup(result.email)
|
||||||
|
setPendingSignup(null)
|
||||||
setWaitlistState("joined")
|
setWaitlistState("joined")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setWaitlistState("idle")
|
|
||||||
if (result.fallback) {
|
if (!result.retryable) {
|
||||||
// API unreachable/broken: fall back to the pre-#87 mailto path so the
|
// The server rejected this address; queueing it would retry forever.
|
||||||
// signup still reaches the support inbox instead of being lost.
|
setWaitlistState(pendingSignup ? "queued" : "idle")
|
||||||
try {
|
|
||||||
await Linking.openURL(buildWaitlistMailtoUrl(result.email))
|
|
||||||
} catch {
|
|
||||||
// No mail app either — tell the user instead of failing silently.
|
|
||||||
Alert.alert(t("connection.add.waitlist.alertTitle"), t("connection.add.waitlist.fallbackMessage"))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Alert.alert(t("connection.add.waitlist.alertTitle"), result.error)
|
Alert.alert(t("connection.add.waitlist.alertTitle"), result.error)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Offline / timeout / 5xx: persist and retry later instead of dumping the
|
||||||
|
// user into a mail composer they may never send (AGE-87).
|
||||||
|
const entry = await queuePendingSignup(result.email, result.error)
|
||||||
|
if (entry) {
|
||||||
|
setPendingSignup(entry)
|
||||||
|
setWaitlistState("queued")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storage refused the write — we cannot promise to finish this later, so
|
||||||
|
// offer the manual email path explicitly rather than claiming success.
|
||||||
|
setWaitlistState("idle")
|
||||||
|
Alert.alert(t("connection.add.waitlist.alertTitle"), t("connection.add.waitlist.queueFailedMessage"), [
|
||||||
|
{ text: t("common.cancel"), style: "cancel" },
|
||||||
|
{ text: t("connection.add.waitlist.emailUsButton"), onPress: () => void openWaitlistMailto(result.email) },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last-resort, user-initiated only. Never opened automatically.
|
||||||
|
const openWaitlistMailto = async (email: string) => {
|
||||||
|
try {
|
||||||
|
await Linking.openURL(buildWaitlistMailtoUrl(email))
|
||||||
|
} catch {
|
||||||
|
Alert.alert(t("connection.add.waitlist.alertTitle"), t("connection.add.waitlist.noMailAppMessage"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit "Retry" from the queued state — same code path the foreground
|
||||||
|
// flush uses, so there is only one retry implementation.
|
||||||
|
const handleRetryQueued = async () => {
|
||||||
|
setWaitlistState("submitting")
|
||||||
|
const outcome = await flushPendingSignups().catch(() => null)
|
||||||
|
if (outcome && outcome.pending.length === 0 && outcome.synced.length > 0) {
|
||||||
|
setPendingSignup(null)
|
||||||
|
setWaitlistState("joined")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (outcome && outcome.pending.length === 0) {
|
||||||
|
// Nothing left pending and nothing synced: the address was rejected.
|
||||||
|
setPendingSignup(null)
|
||||||
|
setWaitlistState("idle")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (outcome) setPendingSignup(outcome.pending[outcome.pending.length - 1])
|
||||||
|
setWaitlistState("queued")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quick connect mode - simplified
|
// Quick connect mode - simplified
|
||||||
@@ -376,6 +449,27 @@ export default function AddConnectionScreen() {
|
|||||||
{t("connection.add.waitlist.successText")}
|
{t("connection.add.waitlist.successText")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
) : waitlistState === "queued" ? (
|
||||||
|
<View testID="waitlist-queued">
|
||||||
|
<View style={styles.waitlistSuccess}>
|
||||||
|
<Ionicons name="time-outline" size={20} color="#f59e0b" />
|
||||||
|
<Text style={[styles.waitlistSuccessText, isDark && styles.textDark]}>
|
||||||
|
{t("connection.add.waitlist.queuedText")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{needsManualEscapeHatch(pendingSignup) && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.waitlistEscapeHatch}
|
||||||
|
onPress={() => void openWaitlistMailto(pendingSignup?.email ?? waitlistEmail)}
|
||||||
|
testID="waitlist-email-us"
|
||||||
|
>
|
||||||
|
<Text style={styles.waitlistEscapeHatchText}>{t("connection.add.waitlist.emailUsLink")}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
<TouchableOpacity style={styles.waitlistEscapeHatch} onPress={() => void handleRetryQueued()} testID="waitlist-retry">
|
||||||
|
<Text style={styles.waitlistEscapeHatchText}>{t("common.retry")}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -834,4 +928,13 @@ const styles = StyleSheet.create({
|
|||||||
color: "#0a0a0a",
|
color: "#0a0a0a",
|
||||||
lineHeight: 20,
|
lineHeight: 20,
|
||||||
},
|
},
|
||||||
|
waitlistEscapeHatch: {
|
||||||
|
marginTop: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
},
|
||||||
|
waitlistEscapeHatchText: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#6366f1",
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -29,10 +29,14 @@ returns 0 results) and the app is not on IzzyOnDroid, so those channels contribu
|
|||||||
the owner manually re-downloads. The hourly reconciler
|
the owner manually re-downloads. The hourly reconciler
|
||||||
(`VibeBrowserProductPage/.github/workflows/waitlist-mailto-reconcile.yml`) is what keeps those
|
(`VibeBrowserProductPage/.github/workflows/waitlist-mailto-reconcile.yml`) is what keeps those
|
||||||
signups from being lost, and it is not a temporary measure.
|
signups from being lost, and it is not a temporary measure.
|
||||||
3. **Stale builds are not the only source of mailto signups.** In current builds the fallback also
|
3. **Stale builds are not the only source of mailto signups** — they are, as of AGE-87, the only
|
||||||
fires on network error, timeout (8s) and 5xx (`src/lib/waitlist.ts:shouldFallbackToMailto`).
|
*remaining* one. Until v0.4.12 the fallback also fired on network error, timeout (8s) and 5xx,
|
||||||
A user on v0.4.12 with flaky mobile data takes the same lossy path. Any plan that assumes
|
so a user on a current build with flaky mobile data took the same lossy path. That path is gone:
|
||||||
"ship an update and the leak closes" is wrong.
|
a failed signup is now persisted on-device (`opencode.waitlist.pending.v1`, AsyncStorage) and
|
||||||
|
retried on every app foreground (`src/lib/waitlist.ts` queue section, flushed from
|
||||||
|
`app/_layout.tsx`). `mailto:` is only ever opened by an explicit user tap after repeated
|
||||||
|
retry failures. Expect the reconciler's `synced_count` to trend toward the sideload cohort only.
|
||||||
|
Any plan that assumes "ship an update and the leak closes" is still wrong for those ~436 devices.
|
||||||
|
|
||||||
## Method / reproducing
|
## Method / reproducing
|
||||||
|
|
||||||
|
|||||||
@@ -181,7 +181,11 @@
|
|||||||
"successText": "You're on the list — we'll email you when OpenCode Connect is ready.",
|
"successText": "You're on the list — we'll email you when OpenCode Connect is ready.",
|
||||||
"joinButton": "Join Waitlist",
|
"joinButton": "Join Waitlist",
|
||||||
"alertTitle": "Join Waitlist",
|
"alertTitle": "Join Waitlist",
|
||||||
"fallbackMessage": "Could not reach the signup service or open an email app. Please email support@agentlabs.cc with subject \"OpenCode Connect Waitlist\"."
|
"queuedText": "Saved on this device — we'll finish signing you up as soon as you're back online. You don't have to do anything.",
|
||||||
|
"emailUsLink": "Still not working? Email us instead",
|
||||||
|
"emailUsButton": "Email us",
|
||||||
|
"queueFailedMessage": "We couldn't reach the signup service or save your request on this device. You can email us instead and we'll add you by hand.",
|
||||||
|
"noMailAppMessage": "No email app is available. Please email support@agentlabs.cc with subject \"OpenCode Connect Waitlist\"."
|
||||||
},
|
},
|
||||||
"advanced": {
|
"advanced": {
|
||||||
"backToQuick": "Simple mode",
|
"backToQuick": "Simple mode",
|
||||||
|
|||||||
@@ -181,7 +181,11 @@
|
|||||||
"successText": "您已加入候补名单 — OpenCode Connect 准备就绪时我们会通过邮件通知您。",
|
"successText": "您已加入候补名单 — OpenCode Connect 准备就绪时我们会通过邮件通知您。",
|
||||||
"joinButton": "加入候补名单",
|
"joinButton": "加入候补名单",
|
||||||
"alertTitle": "加入候补名单",
|
"alertTitle": "加入候补名单",
|
||||||
"fallbackMessage": "无法连接注册服务或打开邮件应用。请发送邮件至 support@agentlabs.cc,主题为 \"OpenCode Connect Waitlist\"。"
|
"queuedText": "已保存在此设备上 — 联网后我们会自动完成注册,你无需再做任何操作。",
|
||||||
|
"emailUsLink": "仍然无法注册?改为给我们发邮件",
|
||||||
|
"emailUsButton": "发送邮件",
|
||||||
|
"queueFailedMessage": "无法连接注册服务,也无法在此设备上保存你的请求。你可以改为给我们发邮件,我们会手动将你加入名单。",
|
||||||
|
"noMailAppMessage": "没有可用的邮件应用。请发送邮件至 support@agentlabs.cc,主题为 \"OpenCode Connect Waitlist\"。"
|
||||||
},
|
},
|
||||||
"advanced": {
|
"advanced": {
|
||||||
"backToQuick": "简易模式",
|
"backToQuick": "简易模式",
|
||||||
|
|||||||
35
src/lib/waitlist-queue-storage.ts
Normal file
35
src/lib/waitlist-queue-storage.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// Production wiring for the waitlist retry queue (AGE-87).
|
||||||
|
//
|
||||||
|
// Split out from waitlist-queue.ts so the queue logic stays importable by
|
||||||
|
// `node --test` (no react-native / AsyncStorage native module). This file is
|
||||||
|
// the only place that touches device storage.
|
||||||
|
//
|
||||||
|
// AsyncStorage (not SecureStore) on purpose: a pending waitlist email is not a
|
||||||
|
// credential, and this queue must survive a keychain that refuses to unlock —
|
||||||
|
// the whole point is that the signup is never silently lost.
|
||||||
|
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage"
|
||||||
|
import { flushQueue, loadQueue, enqueueSignup, removeFromQueue, type FlushOutcome, type QueuedSignup, type QueueStorage } from "./waitlist"
|
||||||
|
|
||||||
|
const storage: QueueStorage = {
|
||||||
|
getItem: (key) => AsyncStorage.getItem(key),
|
||||||
|
setItem: (key, value) => AsyncStorage.setItem(key, value),
|
||||||
|
removeItem: (key) => AsyncStorage.removeItem(key),
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queuePendingSignup(email: string, error?: string): Promise<QueuedSignup | null> {
|
||||||
|
return enqueueSignup(storage, email, { error })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readPendingSignups(): Promise<QueuedSignup[]> {
|
||||||
|
return loadQueue(storage)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dropPendingSignup(email: string): Promise<void> {
|
||||||
|
return removeFromQueue(storage, email)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort retry of every pending signup. Safe to call on every foreground. */
|
||||||
|
export function flushPendingSignups(): Promise<FlushOutcome> {
|
||||||
|
return flushQueue(storage)
|
||||||
|
}
|
||||||
244
src/lib/waitlist-queue.test.ts
Normal file
244
src/lib/waitlist-queue.test.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import { test } from "node:test"
|
||||||
|
import assert from "node:assert/strict"
|
||||||
|
import {
|
||||||
|
WAITLIST_QUEUE_KEY,
|
||||||
|
WAITLIST_QUEUE_MAX,
|
||||||
|
WAITLIST_QUEUE_TTL_MS,
|
||||||
|
enqueueSignup,
|
||||||
|
flushQueue,
|
||||||
|
loadQueue,
|
||||||
|
needsManualEscapeHatch,
|
||||||
|
pruneQueue,
|
||||||
|
removeFromQueue,
|
||||||
|
type QueueStorage,
|
||||||
|
type QueuedSignup,
|
||||||
|
type WaitlistResult,
|
||||||
|
} from "./waitlist.ts"
|
||||||
|
|
||||||
|
// In-memory stand-in for AsyncStorage. `fail` makes every write throw, which is
|
||||||
|
// how a device with full/locked storage behaves.
|
||||||
|
function memoryStorage(initial?: string, opts: { failWrites?: boolean } = {}) {
|
||||||
|
const map = new Map<string, string>()
|
||||||
|
if (initial !== undefined) map.set(WAITLIST_QUEUE_KEY, initial)
|
||||||
|
const storage: QueueStorage & { map: Map<string, string> } = {
|
||||||
|
map,
|
||||||
|
async getItem(key) {
|
||||||
|
return map.get(key) ?? null
|
||||||
|
},
|
||||||
|
async setItem(key, value) {
|
||||||
|
if (opts.failWrites) throw new Error("storage full")
|
||||||
|
map.set(key, value)
|
||||||
|
},
|
||||||
|
async removeItem(key) {
|
||||||
|
if (opts.failWrites) throw new Error("storage full")
|
||||||
|
map.delete(key)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return storage
|
||||||
|
}
|
||||||
|
|
||||||
|
function stored(storage: { map: Map<string, string> }): QueuedSignup[] {
|
||||||
|
const raw = storage.map.get(WAITLIST_QUEUE_KEY)
|
||||||
|
return raw ? (JSON.parse(raw) as QueuedSignup[]) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = (email: string): WaitlistResult => ({ ok: true, email })
|
||||||
|
const offline = (email: string): WaitlistResult => ({ ok: false, email, retryable: true, error: "Network request failed" })
|
||||||
|
const rejected = (email: string): WaitlistResult => ({ ok: false, email, retryable: false, error: "Enter a valid email address." })
|
||||||
|
|
||||||
|
// --- enqueue ---
|
||||||
|
|
||||||
|
test("enqueue persists a normalized signup with attempt 1", async () => {
|
||||||
|
const storage = memoryStorage()
|
||||||
|
const entry = await enqueueSignup(storage, " Dev@Example.COM ", { now: 1_000, error: "Network request failed" })
|
||||||
|
assert.deepEqual(entry, {
|
||||||
|
email: "dev@example.com",
|
||||||
|
queuedAt: 1_000,
|
||||||
|
attempts: 1,
|
||||||
|
lastAttemptAt: 1_000,
|
||||||
|
lastError: "Network request failed",
|
||||||
|
})
|
||||||
|
assert.deepEqual(stored(storage), [entry])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("enqueue dedupes by email and bumps attempts instead of growing the queue", async () => {
|
||||||
|
const storage = memoryStorage()
|
||||||
|
await enqueueSignup(storage, "dev@example.com", { now: 1_000 })
|
||||||
|
const second = await enqueueSignup(storage, "DEV@example.com", { now: 2_000 })
|
||||||
|
assert.equal(stored(storage).length, 1)
|
||||||
|
assert.equal(second?.attempts, 2)
|
||||||
|
assert.equal(second?.queuedAt, 1_000, "original queue time is preserved")
|
||||||
|
assert.equal(second?.lastAttemptAt, 2_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("enqueue caps the queue at WAITLIST_QUEUE_MAX, keeping the newest", async () => {
|
||||||
|
const storage = memoryStorage()
|
||||||
|
for (let i = 0; i < WAITLIST_QUEUE_MAX + 3; i++) {
|
||||||
|
await enqueueSignup(storage, `user${i}@example.com`, { now: 1_000 + i })
|
||||||
|
}
|
||||||
|
const queue = stored(storage)
|
||||||
|
assert.equal(queue.length, WAITLIST_QUEUE_MAX)
|
||||||
|
assert.equal(queue[queue.length - 1].email, `user${WAITLIST_QUEUE_MAX + 2}@example.com`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("enqueue refuses an invalid email — nothing is stored, caller must not claim success", async () => {
|
||||||
|
const storage = memoryStorage()
|
||||||
|
assert.equal(await enqueueSignup(storage, "not-an-email"), null)
|
||||||
|
assert.equal(storage.map.size, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("enqueue returns null when storage refuses the write (never lie about saving)", async () => {
|
||||||
|
const storage = memoryStorage(undefined, { failWrites: true })
|
||||||
|
assert.equal(await enqueueSignup(storage, "dev@example.com", { now: 1 }), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- load resilience: a corrupt blob must not brick the screen ---
|
||||||
|
|
||||||
|
test("load tolerates missing, corrupt, non-array and foreign-shaped payloads", async () => {
|
||||||
|
assert.deepEqual(await loadQueue(memoryStorage()), [])
|
||||||
|
assert.deepEqual(await loadQueue(memoryStorage("{not json")), [])
|
||||||
|
assert.deepEqual(await loadQueue(memoryStorage('{"email":"a@b.co"}')), [])
|
||||||
|
assert.deepEqual(await loadQueue(memoryStorage('[{"nope":1},null,3]')), [])
|
||||||
|
assert.deepEqual(
|
||||||
|
await loadQueue(memoryStorage('[{"email":"NOT normalized","queuedAt":1,"attempts":1}]')),
|
||||||
|
[],
|
||||||
|
"entries that were never normalized are dropped rather than replayed as garbage",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("load backfills lastAttemptAt for entries written before that field existed", async () => {
|
||||||
|
const storage = memoryStorage('[{"email":"dev@example.com","queuedAt":42,"attempts":1}]')
|
||||||
|
assert.deepEqual(await loadQueue(storage), [
|
||||||
|
{ email: "dev@example.com", queuedAt: 42, attempts: 1, lastAttemptAt: 42 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("load survives a storage read that throws", async () => {
|
||||||
|
const storage: QueueStorage = {
|
||||||
|
async getItem() {
|
||||||
|
throw new Error("AsyncStorage unavailable")
|
||||||
|
},
|
||||||
|
async setItem() {},
|
||||||
|
async removeItem() {},
|
||||||
|
}
|
||||||
|
assert.deepEqual(await loadQueue(storage), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- TTL ---
|
||||||
|
|
||||||
|
test("entries older than the TTL are pruned", () => {
|
||||||
|
const fresh: QueuedSignup = { email: "a@b.co", queuedAt: 1_000, attempts: 1, lastAttemptAt: 1_000 }
|
||||||
|
const stale: QueuedSignup = { email: "c@d.co", queuedAt: 0, attempts: 9, lastAttemptAt: 0 }
|
||||||
|
assert.deepEqual(pruneQueue([fresh, stale], WAITLIST_QUEUE_TTL_MS + 500), [fresh])
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- flush: the AGE-87 acceptance test ---
|
||||||
|
|
||||||
|
test("offline signup reaches the server on the next flush, with no mail client involved", async () => {
|
||||||
|
const storage = memoryStorage()
|
||||||
|
|
||||||
|
// 1. User taps "Join Waitlist" on a plane: the POST fails, we queue it.
|
||||||
|
const failure = offline("dev@example.com")
|
||||||
|
assert.equal(failure.ok, false)
|
||||||
|
const queued = await enqueueSignup(storage, "dev@example.com", { now: 1_000, error: failure.error })
|
||||||
|
assert.equal(queued?.attempts, 1)
|
||||||
|
|
||||||
|
// 2. Still offline on the next foreground: kept, attempts bumped.
|
||||||
|
const attempted: string[] = []
|
||||||
|
const stillOffline = await flushQueue(storage, {
|
||||||
|
now: 2_000,
|
||||||
|
submit: async (email) => {
|
||||||
|
attempted.push(email)
|
||||||
|
return offline(email)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert.deepEqual(stillOffline.synced, [])
|
||||||
|
assert.deepEqual(stillOffline.pending.map((e) => e.attempts), [2])
|
||||||
|
assert.deepEqual(stored(storage).map((e) => e.email), ["dev@example.com"])
|
||||||
|
|
||||||
|
// 3. Device reconnects: the queued signup lands in Brevo list 4 and the
|
||||||
|
// queue empties. No mailto: URL was ever built.
|
||||||
|
const reconnected = await flushQueue(storage, {
|
||||||
|
now: 3_000,
|
||||||
|
submit: async (email) => {
|
||||||
|
attempted.push(email)
|
||||||
|
return ok(email)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert.deepEqual(reconnected.synced, ["dev@example.com"])
|
||||||
|
assert.deepEqual(reconnected.pending, [])
|
||||||
|
assert.equal(storage.map.has(WAITLIST_QUEUE_KEY), false, "queue key is removed once empty")
|
||||||
|
assert.deepEqual(attempted, ["dev@example.com", "dev@example.com"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("flush drops entries the server permanently rejects (4xx) instead of retrying forever", async () => {
|
||||||
|
const storage = memoryStorage()
|
||||||
|
await enqueueSignup(storage, "dev@example.com", { now: 1 })
|
||||||
|
const outcome = await flushQueue(storage, { now: 2, submit: async (email) => rejected(email) })
|
||||||
|
assert.deepEqual(outcome.rejected, ["dev@example.com"])
|
||||||
|
assert.deepEqual(outcome.pending, [])
|
||||||
|
assert.deepEqual(stored(storage), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("flush keeps the other entries when one submit throws", async () => {
|
||||||
|
const storage = memoryStorage()
|
||||||
|
await enqueueSignup(storage, "a@example.com", { now: 1 })
|
||||||
|
await enqueueSignup(storage, "b@example.com", { now: 2 })
|
||||||
|
const outcome = await flushQueue(storage, {
|
||||||
|
now: 3,
|
||||||
|
submit: async (email) => {
|
||||||
|
if (email === "a@example.com") throw new TypeError("boom")
|
||||||
|
return ok(email)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert.deepEqual(outcome.synced, ["b@example.com"])
|
||||||
|
assert.deepEqual(outcome.pending.map((e) => e.email), ["a@example.com"])
|
||||||
|
assert.match(outcome.pending[0].lastError ?? "", /boom/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("flush prunes expired entries without submitting them", async () => {
|
||||||
|
const storage = memoryStorage()
|
||||||
|
await enqueueSignup(storage, "old@example.com", { now: 0 })
|
||||||
|
let called = 0
|
||||||
|
const outcome = await flushQueue(storage, {
|
||||||
|
now: WAITLIST_QUEUE_TTL_MS + 1,
|
||||||
|
submit: async (email) => {
|
||||||
|
called++
|
||||||
|
return ok(email)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert.equal(called, 0)
|
||||||
|
assert.deepEqual(outcome, { synced: [], rejected: [], pending: [] })
|
||||||
|
assert.deepEqual(stored(storage), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("flush on an empty queue is a no-op", async () => {
|
||||||
|
const storage = memoryStorage()
|
||||||
|
const outcome = await flushQueue(storage, { submit: async () => assert.fail("must not submit") })
|
||||||
|
assert.deepEqual(outcome, { synced: [], rejected: [], pending: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- manual escape hatch policy ---
|
||||||
|
|
||||||
|
test("the manual email escape hatch appears only after repeated failures", async () => {
|
||||||
|
const storage = memoryStorage()
|
||||||
|
let entry = await enqueueSignup(storage, "dev@example.com", { now: 1 })
|
||||||
|
assert.equal(needsManualEscapeHatch(entry), false)
|
||||||
|
entry = await enqueueSignup(storage, "dev@example.com", { now: 2 })
|
||||||
|
assert.equal(needsManualEscapeHatch(entry), false)
|
||||||
|
entry = await enqueueSignup(storage, "dev@example.com", { now: 3 })
|
||||||
|
assert.equal(needsManualEscapeHatch(entry), true, "3rd failed attempt -> offer 'still not working? email us'")
|
||||||
|
assert.equal(needsManualEscapeHatch(null), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- explicit removal ---
|
||||||
|
|
||||||
|
test("removeFromQueue drops just that email", async () => {
|
||||||
|
const storage = memoryStorage()
|
||||||
|
await enqueueSignup(storage, "a@example.com", { now: 1 })
|
||||||
|
await enqueueSignup(storage, "b@example.com", { now: 2 })
|
||||||
|
await removeFromQueue(storage, "a@example.com")
|
||||||
|
assert.deepEqual(stored(storage).map((e) => e.email), ["b@example.com"])
|
||||||
|
await removeFromQueue(storage, "missing@example.com") // no-op, no throw
|
||||||
|
assert.deepEqual(stored(storage).map((e) => e.email), ["b@example.com"])
|
||||||
|
})
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
normalizeWaitlistEmail,
|
normalizeWaitlistEmail,
|
||||||
buildWaitlistPayload,
|
buildWaitlistPayload,
|
||||||
buildWaitlistMailtoUrl,
|
buildWaitlistMailtoUrl,
|
||||||
shouldFallbackToMailto,
|
isRetryableFailure,
|
||||||
submitWaitlistSignup,
|
submitWaitlistSignup,
|
||||||
} from "./waitlist.ts"
|
} from "./waitlist.ts"
|
||||||
|
|
||||||
@@ -34,32 +34,32 @@ test("payload tags the signup with the opencode-connect source", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- mailto fallback URL: byte-compatible with the pre-#87 behavior ---
|
// --- mailto URL: last-resort escape hatch only (AGE-87), byte-compatible ---
|
||||||
|
|
||||||
test("mailto fallback preserves subject and embeds the email", () => {
|
test("mailto escape hatch preserves subject and embeds the email", () => {
|
||||||
const url = buildWaitlistMailtoUrl("dev@example.com")
|
const url = buildWaitlistMailtoUrl("dev@example.com")
|
||||||
assert.ok(url.startsWith("mailto:support@agentlabs.cc?"))
|
assert.ok(url.startsWith("mailto:support@agentlabs.cc?"))
|
||||||
assert.ok(url.includes("subject=OpenCode%20Connect%20Waitlist"))
|
assert.ok(url.includes("subject=OpenCode%20Connect%20Waitlist"))
|
||||||
assert.ok(url.includes(encodeURIComponent("Email: dev@example.com")))
|
assert.ok(url.includes(encodeURIComponent("Email: dev@example.com")))
|
||||||
})
|
})
|
||||||
|
|
||||||
test("mailto fallback works without an email", () => {
|
test("mailto escape hatch works without an email", () => {
|
||||||
const url = buildWaitlistMailtoUrl("")
|
const url = buildWaitlistMailtoUrl("")
|
||||||
assert.ok(url.includes("body=Sign%20me%20up!"))
|
assert.ok(url.includes("body=Sign%20me%20up!"))
|
||||||
assert.ok(!url.includes("Email"))
|
assert.ok(!url.includes("Email"))
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- fallback decision ---
|
// --- retry decision ---
|
||||||
|
|
||||||
// 502 named explicitly: the server returns it when Brevo itself fails, and the
|
// 502 named explicitly: the server returns it when Brevo itself fails, and the
|
||||||
// signup must survive that via mailto.
|
// signup must survive that by being queued and retried (AGE-87), not mailed.
|
||||||
test("fallback: transport failures and 5xx (incl. 502 Brevo failure) -> mailto, 4xx -> fix input", () => {
|
test("retryable: transport failures and 5xx (incl. 502 Brevo failure) -> retry, 4xx -> fix input", () => {
|
||||||
assert.equal(shouldFallbackToMailto({ kind: "network-error" }), true)
|
assert.equal(isRetryableFailure({ kind: "network-error" }), true)
|
||||||
assert.equal(shouldFallbackToMailto({ kind: "http", status: 500 }), true)
|
assert.equal(isRetryableFailure({ kind: "http", status: 500 }), true)
|
||||||
assert.equal(shouldFallbackToMailto({ kind: "http", status: 502 }), true)
|
assert.equal(isRetryableFailure({ kind: "http", status: 502 }), true)
|
||||||
assert.equal(shouldFallbackToMailto({ kind: "http", status: 503 }), true)
|
assert.equal(isRetryableFailure({ kind: "http", status: 503 }), true)
|
||||||
assert.equal(shouldFallbackToMailto({ kind: "http", status: 400 }), false)
|
assert.equal(isRetryableFailure({ kind: "http", status: 400 }), false)
|
||||||
assert.equal(shouldFallbackToMailto({ kind: "http", status: 429 }), false)
|
assert.equal(isRetryableFailure({ kind: "http", status: 429 }), false)
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- submitWaitlistSignup with injected fetch ---
|
// --- submitWaitlistSignup with injected fetch ---
|
||||||
@@ -89,37 +89,37 @@ test("submit rejects an invalid email locally without hitting the network", asyn
|
|||||||
const calls: FetchCall[] = []
|
const calls: FetchCall[] = []
|
||||||
const result = await submitWaitlistSignup("nope", { fetchFn: fakeFetch({ ok: true, status: 200 }, calls) })
|
const result = await submitWaitlistSignup("nope", { fetchFn: fakeFetch({ ok: true, status: 200 }, calls) })
|
||||||
assert.equal(calls.length, 0)
|
assert.equal(calls.length, 0)
|
||||||
assert.deepEqual(result, { ok: false, email: "nope", fallback: false, error: "Enter a valid email address." })
|
assert.deepEqual(result, { ok: false, email: "nope", retryable: false, error: "Enter a valid email address." })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("submit surfaces the server's 400 message without falling back to mailto", async () => {
|
test("submit surfaces the server's 400 message and marks it non-retryable", async () => {
|
||||||
const result = await submitWaitlistSignup("dev@example.com", {
|
const result = await submitWaitlistSignup("dev@example.com", {
|
||||||
fetchFn: fakeFetch({ ok: false, status: 400, body: { error: "Enter a valid email address." } }),
|
fetchFn: fakeFetch({ ok: false, status: 400, body: { error: "Enter a valid email address." } }),
|
||||||
})
|
})
|
||||||
assert.deepEqual(result, { ok: false, email: "dev@example.com", fallback: false, error: "Enter a valid email address." })
|
assert.deepEqual(result, { ok: false, email: "dev@example.com", retryable: false, error: "Enter a valid email address." })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("submit falls back to mailto on 5xx (server broken, keep the signup alive)", async () => {
|
test("submit marks 5xx retryable (server broken, keep the signup alive)", async () => {
|
||||||
const result = await submitWaitlistSignup("dev@example.com", {
|
const result = await submitWaitlistSignup("dev@example.com", {
|
||||||
fetchFn: fakeFetch({ ok: false, status: 503, body: { error: "The waitlist is temporarily unavailable. Please try again later." } }),
|
fetchFn: fakeFetch({ ok: false, status: 503, body: { error: "The waitlist is temporarily unavailable. Please try again later." } }),
|
||||||
})
|
})
|
||||||
assert.equal(result.ok, false)
|
assert.equal(result.ok, false)
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
assert.equal(result.fallback, true)
|
assert.equal(result.retryable, true)
|
||||||
assert.equal(result.error, "The waitlist is temporarily unavailable. Please try again later.")
|
assert.equal(result.error, "The waitlist is temporarily unavailable. Please try again later.")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("submit falls back to mailto when fetch rejects (offline)", async () => {
|
test("submit marks a rejected fetch retryable (offline)", async () => {
|
||||||
const result = await submitWaitlistSignup("dev@example.com", {
|
const result = await submitWaitlistSignup("dev@example.com", {
|
||||||
fetchFn: async () => {
|
fetchFn: async () => {
|
||||||
throw new TypeError("Network request failed")
|
throw new TypeError("Network request failed")
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
assert.deepEqual(result, { ok: false, email: "dev@example.com", fallback: true, error: "Network request failed" })
|
assert.deepEqual(result, { ok: false, email: "dev@example.com", retryable: true, error: "Network request failed" })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("submit aborts after timeoutMs and falls back to mailto", async () => {
|
test("submit aborts after timeoutMs and marks it retryable", async () => {
|
||||||
const result = await submitWaitlistSignup("dev@example.com", {
|
const result = await submitWaitlistSignup("dev@example.com", {
|
||||||
timeoutMs: 20,
|
timeoutMs: 20,
|
||||||
fetchFn: (_url, init) =>
|
fetchFn: (_url, init) =>
|
||||||
@@ -131,7 +131,7 @@ test("submit aborts after timeoutMs and falls back to mailto", async () => {
|
|||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
assert.deepEqual(result, { ok: false, email: "dev@example.com", fallback: true, error: "timeout after 20ms" })
|
assert.deepEqual(result, { ok: false, email: "dev@example.com", retryable: true, error: "timeout after 20ms" })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("submit tolerates a non-JSON error body", async () => {
|
test("submit tolerates a non-JSON error body", async () => {
|
||||||
@@ -144,5 +144,5 @@ test("submit tolerates a non-JSON error body", async () => {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
assert.deepEqual(result, { ok: false, email: "dev@example.com", fallback: true, error: "Signup failed (HTTP 502)." })
|
assert.deepEqual(result, { ok: false, email: "dev@example.com", retryable: true, error: "Signup failed (HTTP 502)." })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -31,7 +31,12 @@ export function buildWaitlistPayload(email: string): { email: string; source: st
|
|||||||
return { email, source: WAITLIST_SOURCE }
|
return { email, source: WAITLIST_SOURCE }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The pre-#87 mailto path, kept as the fallback when the API is unreachable. */
|
/**
|
||||||
|
* Last-resort, USER-INITIATED escape hatch (AGE-87). Never open this
|
||||||
|
* automatically: a failed signup is queued locally and retried
|
||||||
|
* (waitlist-queue.ts). This URL is only offered after retries keep failing,
|
||||||
|
* behind an explicit "still not working? email us" tap.
|
||||||
|
*/
|
||||||
export function buildWaitlistMailtoUrl(email: string): string {
|
export function buildWaitlistMailtoUrl(email: string): string {
|
||||||
const subject = encodeURIComponent("OpenCode Connect Waitlist")
|
const subject = encodeURIComponent("OpenCode Connect Waitlist")
|
||||||
const body = encodeURIComponent(email ? `Sign me up!\n\nEmail: ${email}` : "Sign me up!")
|
const body = encodeURIComponent(email ? `Sign me up!\n\nEmail: ${email}` : "Sign me up!")
|
||||||
@@ -41,19 +46,19 @@ export function buildWaitlistMailtoUrl(email: string): string {
|
|||||||
export type WaitlistResult =
|
export type WaitlistResult =
|
||||||
/** Signup persisted server-side. */
|
/** Signup persisted server-side. */
|
||||||
| { ok: true; email: string }
|
| { ok: true; email: string }
|
||||||
/** Signup not persisted. `fallback` decides the UX: true -> open the
|
/** Signup not persisted. `retryable` decides the UX: true -> the same
|
||||||
* mailto fallback so the signup still reaches the support inbox;
|
* request can succeed later, so queue it and retry on reconnect;
|
||||||
* false -> the input (or server validation) is wrong, ask the user to fix
|
* false -> the input (or server validation) is wrong, ask the user to fix
|
||||||
* their email instead of mailing garbage. */
|
* their email instead of queueing garbage forever. */
|
||||||
| { ok: false; email: string; fallback: boolean; error: string }
|
| { ok: false; email: string; retryable: boolean; error: string }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fallback decision, isolated for testability:
|
* Retry decision, isolated for testability:
|
||||||
* - transport failure (offline, DNS, timeout) -> mailto keeps the signup alive
|
* - transport failure (offline, DNS, timeout) -> retry when connectivity returns
|
||||||
* - 5xx -> server broken through no fault of the user's -> mailto
|
* - 5xx -> server broken through no fault of the user's -> retry
|
||||||
* - 4xx -> the server rejected this email; mailing it wouldn't help
|
* - 4xx -> the server rejected this email; repeating it wouldn't help
|
||||||
*/
|
*/
|
||||||
export function shouldFallbackToMailto(outcome: { kind: "network-error" } | { kind: "http"; status: number }): boolean {
|
export function isRetryableFailure(outcome: { kind: "network-error" } | { kind: "http"; status: number }): boolean {
|
||||||
if (outcome.kind === "network-error") return true
|
if (outcome.kind === "network-error") return true
|
||||||
return outcome.status >= 500
|
return outcome.status >= 500
|
||||||
}
|
}
|
||||||
@@ -78,7 +83,7 @@ function serverError(body: unknown): string | undefined {
|
|||||||
export async function submitWaitlistSignup(rawEmail: string, deps: WaitlistDeps = {}): Promise<WaitlistResult> {
|
export async function submitWaitlistSignup(rawEmail: string, deps: WaitlistDeps = {}): Promise<WaitlistResult> {
|
||||||
const email = normalizeWaitlistEmail(rawEmail)
|
const email = normalizeWaitlistEmail(rawEmail)
|
||||||
if (email === null) {
|
if (email === null) {
|
||||||
return { ok: false, email: rawEmail.trim(), fallback: false, error: "Enter a valid email address." }
|
return { ok: false, email: rawEmail.trim(), retryable: false, error: "Enter a valid email address." }
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchFn = deps.fetchFn ?? (fetch as unknown as FetchLike)
|
const fetchFn = deps.fetchFn ?? (fetch as unknown as FetchLike)
|
||||||
@@ -99,7 +104,7 @@ export async function submitWaitlistSignup(rawEmail: string, deps: WaitlistDeps
|
|||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
email,
|
email,
|
||||||
fallback: shouldFallbackToMailto({ kind: "http", status: res.status }),
|
retryable: isRetryableFailure({ kind: "http", status: res.status }),
|
||||||
error: message || `Signup failed (HTTP ${res.status}).`,
|
error: message || `Signup failed (HTTP ${res.status}).`,
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -108,10 +113,227 @@ export async function submitWaitlistSignup(rawEmail: string, deps: WaitlistDeps
|
|||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
email,
|
email,
|
||||||
fallback: shouldFallbackToMailto({ kind: "network-error" }),
|
retryable: isRetryableFailure({ kind: "network-error" }),
|
||||||
error: aborted ? `timeout after ${timeoutMs}ms` : err?.message || String(error),
|
error: aborted ? `timeout after ${timeoutMs}ms` : err?.message || String(error),
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Durable retry queue (AGE-87)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Before this, a signup that hit a network error / 8s timeout / 5xx was handed
|
||||||
|
// straight to a `mailto:` composer. That is lossy by design: it depends on the
|
||||||
|
// user actually pressing send in their mail client, and on us reconciling the
|
||||||
|
// support inbox into Brevo list 4 forever (AGE-61's hourly reconciler). 20 of
|
||||||
|
// 21 signups were lost that way before that reconciler existed.
|
||||||
|
//
|
||||||
|
// Instead we persist the pending signup on the device and retry it on the next
|
||||||
|
// foreground / connectivity. `mailto:` survives only as a last-resort,
|
||||||
|
// USER-INITIATED escape hatch once retries are clearly not working.
|
||||||
|
//
|
||||||
|
// Lives in this file (rather than its own module) so it stays a dependency-free
|
||||||
|
// leaf that `node --test` can run directly, the same constraint that keeps
|
||||||
|
// react-native imports out of here. Storage and the clock are injected; the
|
||||||
|
// production AsyncStorage adapter is waitlist-queue-storage.ts.
|
||||||
|
|
||||||
|
export const WAITLIST_QUEUE_KEY = "opencode.waitlist.pending.v1"
|
||||||
|
|
||||||
|
/** Cap the queue so a broken device can't grow storage without bound. */
|
||||||
|
export const WAITLIST_QUEUE_MAX = 5
|
||||||
|
|
||||||
|
/** After this many failed attempts the UI offers the manual email escape hatch. */
|
||||||
|
export const WAITLIST_QUEUE_ATTEMPTS_BEFORE_MANUAL = 3
|
||||||
|
|
||||||
|
/** Entries older than this are dropped: the address is stale, the user moved on. */
|
||||||
|
export const WAITLIST_QUEUE_TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 days
|
||||||
|
|
||||||
|
export interface QueuedSignup {
|
||||||
|
/** Already normalized (trimmed/lowercased) by normalizeWaitlistEmail. */
|
||||||
|
email: string
|
||||||
|
/** Epoch ms of the first attempt. */
|
||||||
|
queuedAt: number
|
||||||
|
/** Number of failed submit attempts so far (>= 1 — enqueue follows a failure). */
|
||||||
|
attempts: number
|
||||||
|
/** Epoch ms of the last attempt. */
|
||||||
|
lastAttemptAt: number
|
||||||
|
/** Last transport/server error, for diagnostics only. */
|
||||||
|
lastError?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal storage port. AsyncStorage satisfies this structurally, so does a
|
||||||
|
* plain Map in tests. Deliberately not typed against AsyncStorage so this file
|
||||||
|
* stays importable by `node --test`.
|
||||||
|
*/
|
||||||
|
export interface QueueStorage {
|
||||||
|
getItem(key: string): Promise<string | null>
|
||||||
|
setItem(key: string, value: string): Promise<void>
|
||||||
|
removeItem(key: string): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
function isQueuedSignup(value: unknown): value is QueuedSignup {
|
||||||
|
if (typeof value !== "object" || value === null) return false
|
||||||
|
const entry = value as Record<string, unknown>
|
||||||
|
return (
|
||||||
|
typeof entry.email === "string" &&
|
||||||
|
normalizeWaitlistEmail(entry.email) === entry.email &&
|
||||||
|
typeof entry.queuedAt === "number" &&
|
||||||
|
Number.isFinite(entry.queuedAt) &&
|
||||||
|
typeof entry.attempts === "number" &&
|
||||||
|
Number.isFinite(entry.attempts)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the queue, tolerating absent/corrupt/foreign JSON (never throws). */
|
||||||
|
export async function loadQueue(storage: QueueStorage): Promise<QueuedSignup[]> {
|
||||||
|
let raw: string | null = null
|
||||||
|
try {
|
||||||
|
raw = await storage.getItem(WAITLIST_QUEUE_KEY)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
if (!raw) return []
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(raw)
|
||||||
|
if (!Array.isArray(parsed)) return []
|
||||||
|
return parsed.filter(isQueuedSignup).map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
lastAttemptAt: typeof entry.lastAttemptAt === "number" ? entry.lastAttemptAt : entry.queuedAt,
|
||||||
|
}))
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveQueue(storage: QueueStorage, queue: QueuedSignup[]): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (queue.length === 0) await storage.removeItem(WAITLIST_QUEUE_KEY)
|
||||||
|
else await storage.setItem(WAITLIST_QUEUE_KEY, JSON.stringify(queue))
|
||||||
|
} catch {
|
||||||
|
// Storage unavailable: the retry is lost, but the caller already told the
|
||||||
|
// user the truth ("saved, we'll finish signing you up") only when this
|
||||||
|
// resolves. enqueueSignup() surfaces the failure via its return value.
|
||||||
|
throw new Error("waitlist queue storage unavailable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops entries past the TTL. Exported for the test that pins the policy. */
|
||||||
|
export function pruneQueue(queue: QueuedSignup[], now: number): QueuedSignup[] {
|
||||||
|
return queue.filter((entry) => now - entry.queuedAt < WAITLIST_QUEUE_TTL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists a failed signup for later retry. Dedupes by email (an impatient
|
||||||
|
* user tapping twice must not produce two entries) and keeps the newest
|
||||||
|
* WAITLIST_QUEUE_MAX entries.
|
||||||
|
*
|
||||||
|
* Returns the stored entry, or null when the email is invalid or storage
|
||||||
|
* refused the write — in that case the caller must NOT tell the user we saved it.
|
||||||
|
*/
|
||||||
|
export async function enqueueSignup(
|
||||||
|
storage: QueueStorage,
|
||||||
|
rawEmail: string,
|
||||||
|
options: { now?: number; error?: string } = {},
|
||||||
|
): Promise<QueuedSignup | null> {
|
||||||
|
const email = normalizeWaitlistEmail(rawEmail)
|
||||||
|
if (email === null) return null
|
||||||
|
const now = options.now ?? Date.now()
|
||||||
|
|
||||||
|
const existing = pruneQueue(await loadQueue(storage), now)
|
||||||
|
const previous = existing.find((entry) => entry.email === email)
|
||||||
|
const entry: QueuedSignup = {
|
||||||
|
email,
|
||||||
|
queuedAt: previous?.queuedAt ?? now,
|
||||||
|
attempts: (previous?.attempts ?? 0) + 1,
|
||||||
|
lastAttemptAt: now,
|
||||||
|
...(options.error ? { lastError: options.error } : {}),
|
||||||
|
}
|
||||||
|
const next = [...existing.filter((e) => e.email !== email), entry].slice(-WAITLIST_QUEUE_MAX)
|
||||||
|
try {
|
||||||
|
await saveQueue(storage, next)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeFromQueue(storage: QueueStorage, email: string): Promise<void> {
|
||||||
|
const queue = await loadQueue(storage)
|
||||||
|
const next = queue.filter((entry) => entry.email !== email)
|
||||||
|
if (next.length === queue.length) return
|
||||||
|
try {
|
||||||
|
await saveQueue(storage, next)
|
||||||
|
} catch {
|
||||||
|
// best effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlushOutcome {
|
||||||
|
/** Entries that reached the server (Brevo list 4). */
|
||||||
|
synced: string[]
|
||||||
|
/** Entries the server permanently rejected (4xx) — dropped, retrying can't help. */
|
||||||
|
rejected: string[]
|
||||||
|
/** Entries still pending after this flush. */
|
||||||
|
pending: QueuedSignup[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SubmitFn = (email: string) => Promise<WaitlistResult>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retries every pending signup once. Called on app foreground and when the
|
||||||
|
* waitlist screen mounts.
|
||||||
|
*
|
||||||
|
* - ok -> drop (it's in Brevo now)
|
||||||
|
* - 4xx -> drop (the server will never accept this address)
|
||||||
|
* - network/5xx -> keep, bump attempts (retry next foreground)
|
||||||
|
*
|
||||||
|
* Never throws: a flush is a background best-effort action.
|
||||||
|
*/
|
||||||
|
export async function flushQueue(
|
||||||
|
storage: QueueStorage,
|
||||||
|
options: { submit?: SubmitFn; now?: number } = {},
|
||||||
|
): Promise<FlushOutcome> {
|
||||||
|
const now = options.now ?? Date.now()
|
||||||
|
const submit = options.submit ?? ((email: string) => submitWaitlistSignup(email))
|
||||||
|
|
||||||
|
const queue = pruneQueue(await loadQueue(storage), now)
|
||||||
|
const synced: string[] = []
|
||||||
|
const rejected: string[] = []
|
||||||
|
const pending: QueuedSignup[] = []
|
||||||
|
|
||||||
|
for (const entry of queue) {
|
||||||
|
let result: WaitlistResult
|
||||||
|
try {
|
||||||
|
result = await submit(entry.email)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
pending.push({ ...entry, attempts: entry.attempts + 1, lastAttemptAt: now, lastError: String(error) })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (result.ok) {
|
||||||
|
synced.push(entry.email)
|
||||||
|
} else if (!result.retryable) {
|
||||||
|
rejected.push(entry.email)
|
||||||
|
} else {
|
||||||
|
pending.push({ ...entry, attempts: entry.attempts + 1, lastAttemptAt: now, lastError: result.error })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await saveQueue(storage, pending)
|
||||||
|
} catch {
|
||||||
|
// Storage went away mid-flush; the in-memory outcome is still accurate.
|
||||||
|
}
|
||||||
|
return { synced, rejected, pending }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True once an entry has failed enough times that we should stop implying
|
||||||
|
* "we'll handle it" and offer the manual email escape hatch instead.
|
||||||
|
*/
|
||||||
|
export function needsManualEscapeHatch(entry: QueuedSignup | null | undefined): boolean {
|
||||||
|
return !!entry && entry.attempts >= WAITLIST_QUEUE_ATTEMPTS_BEFORE_MANUAL
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user