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 { loadTelemetryConsent, setTelemetryConsent } from "../src/lib/telemetry"
|
||||
import { initAnalytics, trackAppOpened } from "../src/lib/analytics"
|
||||
import { flushPendingSignups } from "../src/lib/waitlist-queue-storage"
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
@@ -95,6 +96,30 @@ function RootLayout() {
|
||||
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
|
||||
useEffect(() => {
|
||||
if (client && !sseStarted.current) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -21,7 +21,8 @@ import { captureDiagnostic } from "../../src/lib/sentry"
|
||||
import { parseUrl } from "../../src/lib/diagnostics-classify"
|
||||
import { buildAuth } from "../../src/lib/auth"
|
||||
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() {
|
||||
const colorScheme = useColorScheme()
|
||||
@@ -41,7 +42,34 @@ export default function AddConnectionScreen() {
|
||||
const [password, setPassword] = useState("")
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
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 = () => {
|
||||
if (mode === "advanced") return url.trim()
|
||||
@@ -213,25 +241,70 @@ export default function AddConnectionScreen() {
|
||||
|
||||
const handleJoinWaitlist = async () => {
|
||||
if (waitlistState === "submitting") return
|
||||
const attemptedEmail = waitlistEmail
|
||||
setWaitlistState("submitting")
|
||||
const result = await submitWaitlistSignup(waitlistEmail)
|
||||
const result = await submitWaitlistSignup(attemptedEmail)
|
||||
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")
|
||||
return
|
||||
}
|
||||
setWaitlistState("idle")
|
||||
if (result.fallback) {
|
||||
// API unreachable/broken: fall back to the pre-#87 mailto path so the
|
||||
// signup still reaches the support inbox instead of being lost.
|
||||
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 {
|
||||
|
||||
if (!result.retryable) {
|
||||
// The server rejected this address; queueing it would retry forever.
|
||||
setWaitlistState(pendingSignup ? "queued" : "idle")
|
||||
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
|
||||
@@ -376,6 +449,27 @@ export default function AddConnectionScreen() {
|
||||
{t("connection.add.waitlist.successText")}
|
||||
</Text>
|
||||
</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
|
||||
@@ -834,4 +928,13 @@ const styles = StyleSheet.create({
|
||||
color: "#0a0a0a",
|
||||
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
|
||||
(`VibeBrowserProductPage/.github/workflows/waitlist-mailto-reconcile.yml`) is what keeps those
|
||||
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
|
||||
fires on network error, timeout (8s) and 5xx (`src/lib/waitlist.ts:shouldFallbackToMailto`).
|
||||
A user on v0.4.12 with flaky mobile data takes the same lossy path. Any plan that assumes
|
||||
"ship an update and the leak closes" is wrong.
|
||||
3. **Stale builds are not the only source of mailto signups** — they are, as of AGE-87, the only
|
||||
*remaining* one. Until v0.4.12 the fallback also fired on network error, timeout (8s) and 5xx,
|
||||
so a user on a current build with flaky mobile data took the same lossy path. That path is gone:
|
||||
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
|
||||
|
||||
|
||||
@@ -181,7 +181,11 @@
|
||||
"successText": "You're on the list — we'll email you when OpenCode Connect is ready.",
|
||||
"joinButton": "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": {
|
||||
"backToQuick": "Simple mode",
|
||||
|
||||
@@ -181,7 +181,11 @@
|
||||
"successText": "您已加入候补名单 — OpenCode Connect 准备就绪时我们会通过邮件通知您。",
|
||||
"joinButton": "加入候补名单",
|
||||
"alertTitle": "加入候补名单",
|
||||
"fallbackMessage": "无法连接注册服务或打开邮件应用。请发送邮件至 support@agentlabs.cc,主题为 \"OpenCode Connect Waitlist\"。"
|
||||
"queuedText": "已保存在此设备上 — 联网后我们会自动完成注册,你无需再做任何操作。",
|
||||
"emailUsLink": "仍然无法注册?改为给我们发邮件",
|
||||
"emailUsButton": "发送邮件",
|
||||
"queueFailedMessage": "无法连接注册服务,也无法在此设备上保存你的请求。你可以改为给我们发邮件,我们会手动将你加入名单。",
|
||||
"noMailAppMessage": "没有可用的邮件应用。请发送邮件至 support@agentlabs.cc,主题为 \"OpenCode Connect Waitlist\"。"
|
||||
},
|
||||
"advanced": {
|
||||
"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,
|
||||
buildWaitlistPayload,
|
||||
buildWaitlistMailtoUrl,
|
||||
shouldFallbackToMailto,
|
||||
isRetryableFailure,
|
||||
submitWaitlistSignup,
|
||||
} 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")
|
||||
assert.ok(url.startsWith("mailto:support@agentlabs.cc?"))
|
||||
assert.ok(url.includes("subject=OpenCode%20Connect%20Waitlist"))
|
||||
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("")
|
||||
assert.ok(url.includes("body=Sign%20me%20up!"))
|
||||
assert.ok(!url.includes("Email"))
|
||||
})
|
||||
|
||||
// --- fallback decision ---
|
||||
// --- retry decision ---
|
||||
|
||||
// 502 named explicitly: the server returns it when Brevo itself fails, and the
|
||||
// signup must survive that via mailto.
|
||||
test("fallback: transport failures and 5xx (incl. 502 Brevo failure) -> mailto, 4xx -> fix input", () => {
|
||||
assert.equal(shouldFallbackToMailto({ kind: "network-error" }), true)
|
||||
assert.equal(shouldFallbackToMailto({ kind: "http", status: 500 }), true)
|
||||
assert.equal(shouldFallbackToMailto({ kind: "http", status: 502 }), true)
|
||||
assert.equal(shouldFallbackToMailto({ kind: "http", status: 503 }), true)
|
||||
assert.equal(shouldFallbackToMailto({ kind: "http", status: 400 }), false)
|
||||
assert.equal(shouldFallbackToMailto({ kind: "http", status: 429 }), false)
|
||||
// signup must survive that by being queued and retried (AGE-87), not mailed.
|
||||
test("retryable: transport failures and 5xx (incl. 502 Brevo failure) -> retry, 4xx -> fix input", () => {
|
||||
assert.equal(isRetryableFailure({ kind: "network-error" }), true)
|
||||
assert.equal(isRetryableFailure({ kind: "http", status: 500 }), true)
|
||||
assert.equal(isRetryableFailure({ kind: "http", status: 502 }), true)
|
||||
assert.equal(isRetryableFailure({ kind: "http", status: 503 }), true)
|
||||
assert.equal(isRetryableFailure({ kind: "http", status: 400 }), false)
|
||||
assert.equal(isRetryableFailure({ kind: "http", status: 429 }), false)
|
||||
})
|
||||
|
||||
// --- submitWaitlistSignup with injected fetch ---
|
||||
@@ -89,37 +89,37 @@ test("submit rejects an invalid email locally without hitting the network", asyn
|
||||
const calls: FetchCall[] = []
|
||||
const result = await submitWaitlistSignup("nope", { fetchFn: fakeFetch({ ok: true, status: 200 }, calls) })
|
||||
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", {
|
||||
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", {
|
||||
fetchFn: fakeFetch({ ok: false, status: 503, body: { error: "The waitlist is temporarily unavailable. Please try again later." } }),
|
||||
})
|
||||
assert.equal(result.ok, false)
|
||||
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.")
|
||||
}
|
||||
})
|
||||
|
||||
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", {
|
||||
fetchFn: async () => {
|
||||
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", {
|
||||
timeoutMs: 20,
|
||||
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 () => {
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
const subject = encodeURIComponent("OpenCode Connect Waitlist")
|
||||
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 =
|
||||
/** Signup persisted server-side. */
|
||||
| { ok: true; email: string }
|
||||
/** Signup not persisted. `fallback` decides the UX: true -> open the
|
||||
* mailto fallback so the signup still reaches the support inbox;
|
||||
/** Signup not persisted. `retryable` decides the UX: true -> the same
|
||||
* request can succeed later, so queue it and retry on reconnect;
|
||||
* false -> the input (or server validation) is wrong, ask the user to fix
|
||||
* their email instead of mailing garbage. */
|
||||
| { ok: false; email: string; fallback: boolean; error: string }
|
||||
* their email instead of queueing garbage forever. */
|
||||
| { ok: false; email: string; retryable: boolean; error: string }
|
||||
|
||||
/**
|
||||
* Fallback decision, isolated for testability:
|
||||
* - transport failure (offline, DNS, timeout) -> mailto keeps the signup alive
|
||||
* - 5xx -> server broken through no fault of the user's -> mailto
|
||||
* - 4xx -> the server rejected this email; mailing it wouldn't help
|
||||
* Retry decision, isolated for testability:
|
||||
* - transport failure (offline, DNS, timeout) -> retry when connectivity returns
|
||||
* - 5xx -> server broken through no fault of the user's -> retry
|
||||
* - 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
|
||||
return outcome.status >= 500
|
||||
}
|
||||
@@ -78,7 +83,7 @@ function serverError(body: unknown): string | undefined {
|
||||
export async function submitWaitlistSignup(rawEmail: string, deps: WaitlistDeps = {}): Promise<WaitlistResult> {
|
||||
const email = normalizeWaitlistEmail(rawEmail)
|
||||
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)
|
||||
@@ -99,7 +104,7 @@ export async function submitWaitlistSignup(rawEmail: string, deps: WaitlistDeps
|
||||
return {
|
||||
ok: false,
|
||||
email,
|
||||
fallback: shouldFallbackToMailto({ kind: "http", status: res.status }),
|
||||
retryable: isRetryableFailure({ kind: "http", status: res.status }),
|
||||
error: message || `Signup failed (HTTP ${res.status}).`,
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
@@ -108,10 +113,227 @@ export async function submitWaitlistSignup(rawEmail: string, deps: WaitlistDeps
|
||||
return {
|
||||
ok: false,
|
||||
email,
|
||||
fallback: shouldFallbackToMailto({ kind: "network-error" }),
|
||||
retryable: isRetryableFailure({ kind: "network-error" }),
|
||||
error: aborted ? `timeout after ${timeoutMs}ms` : err?.message || String(error),
|
||||
}
|
||||
} finally {
|
||||
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