Account lifecycle & the cron
Deletion & guest purge share one path
DELETE /api/engine/me (self-service) and the cron's stale-guest sweep both run
purgeUser, ordered games → Firebase → D1. The order is load-bearing:
because the auth middleware re-provisions a users row on any valid token,
deleting the D1 row while the Firebase account still lives would let the very
next request resurrect the user. So:
- Forfeit / cancel / leave every one of the user's live games (a rated forfeit applies its ratings while the user row still exists).
- Delete the Firebase account (Identity Toolkit admin
accounts:delete). On failure this throws before any D1 write, so nothing is half-deleted and a retry is clean: the route surfaces a 502 ("intact, retry"), never a partial deletion. - Purge D1 as one explicit
batch(): anonymize the seats andcreatedBy(so finished-game history stays readable as "Deleted User"), delete ratings, history, relationships, and device rows, then theusersrow last. Delete the avatar object if present.
The cron backstop
The scheduled handler does only what has no per-entity timer of its own,
notably not a timeout sweep (the DO alarm owns that):
- Stale-guest purge: anonymous accounts past an age with no recent game
activity, torn down through
purgeUser. - Abandoned-game reap: never-started lobbies past a TTL, and untimed active
games (which have no alarm) idle past a longer TTL,
aborted so they stop occupying the lobby and release their DO storage. - Read-model reconciliation: games D1 still believes are live but has stopped hearing from, repaired from the authoritative Durable Object.
All three are best-effort, isolated (one failing never blocks the others), and
batch-capped so a backlog drains over days. Every window and cap is a default
overridable via a lifecycle block on createEngine (guestMaxAgeMs,
guestInactivityMs, lobbyTtlMs, untimedActiveTtlMs, guestBatch,
reapBatch, deadlineGraceMs, mirrorStaleMs, reconcileBatch).
Why reconciliation exists
D1's game rows are a read model: the DO writes them off the response path, because a commit whose truth is already durable must never fail on a display copy. That choice has a cost. A mirror write can be lost after its retries, leaving D1 quietly stale. And a finish whose D1 apply fails keeps its outbox row in the DO, waiting to be asked again — otherwise that game's rating deltas are never written at all, which is the worst outcome available here.
D1 cannot tell which happened, or even that anything did: it holds a plausible row that has simply stopped changing. So the sweep looks for the two ways a live game can be provably quiet — an active game long past its committed turn deadline (whose alarm should have fired and written by now), and any non-terminal game with no D1 update for a week (the only signal available for an untimed game, which has no deadline to be late for) — and asks the object what is true.
The repair itself is idempotent: it rewrites the roster and summary rows from committed state, retries a retained finish, and re-arms the alarm if it disagrees. A healthy game caught by a coarse predicate costs one wake and changes nothing. The same repair is available for a single game through the operator surface.