feat(notifications): add pruneDeadTokens utility for FCM dead-token cleanup

This commit is contained in:
null 2026-06-30 23:34:22 -05:00
parent 38eae8f915
commit b564d1da42
2 changed files with 155 additions and 0 deletions

View File

@ -0,0 +1,63 @@
import { isDeadTokenError, selectDeadTokens } from './pruneTokens'
// Shapes mirror what firebase-admin's FirebaseMessagingError actually throws (see errorInfo.code),
// plus the `code` getter form. Both must be recognised; transient/server errors must NOT be.
const deadNotRegistered = { errorInfo: { code: 'messaging/registration-token-not-registered' }, codePrefix: 'messaging' }
const deadInvalidToken = { code: 'messaging/invalid-registration-token' }
const transientUnavailable = { errorInfo: { code: 'messaging/server-unavailable' } }
const transientInternal = { code: 'messaging/internal-error' }
const badPayload = { errorInfo: { code: 'messaging/invalid-argument' } } // payload bug — must NOT prune
const rej = (reason: unknown): PromiseSettledResult<unknown> => ({ status: 'rejected', reason })
const ful = (): PromiseSettledResult<unknown> => ({ status: 'fulfilled', value: 'id' })
describe('isDeadTokenError', () => {
it('is true for permanently-dead token codes (both errorInfo.code and code shapes)', () => {
expect(isDeadTokenError(deadNotRegistered)).toBe(true)
expect(isDeadTokenError(deadInvalidToken)).toBe(true)
})
it('is false for transient/server errors — never prune on these', () => {
expect(isDeadTokenError(transientUnavailable)).toBe(false)
expect(isDeadTokenError(transientInternal)).toBe(false)
})
it('is false for invalid-argument — that blames the payload, not the token', () => {
expect(isDeadTokenError(badPayload)).toBe(false)
})
it('is false for missing/garbage reasons (fail safe)', () => {
expect(isDeadTokenError(undefined)).toBe(false)
expect(isDeadTokenError(null)).toBe(false)
expect(isDeadTokenError({})).toBe(false)
expect(isDeadTokenError('boom')).toBe(false)
expect(isDeadTokenError({ code: 42 })).toBe(false)
})
})
describe('selectDeadTokens', () => {
it('maps rejected-dead results to their token by index', () => {
const tokens = ['A', 'B', 'C']
const results = [ful(), rej(deadNotRegistered), rej(transientUnavailable)]
expect(selectDeadTokens(tokens, results)).toEqual(['B'])
})
it('ignores fulfilled sends and transient failures', () => {
const tokens = ['A', 'B']
expect(selectDeadTokens(tokens, [ful(), rej(transientInternal)])).toEqual([])
})
it('dedupes when the same dead token appears twice', () => {
const tokens = ['A', 'A']
expect(selectDeadTokens(tokens, [rej(deadNotRegistered), rej(deadInvalidToken)])).toEqual(['A'])
})
it('returns empty when there are no failures', () => {
expect(selectDeadTokens(['A', 'B'], [ful(), ful()])).toEqual([])
})
it('never selects a token whose index is missing', () => {
// more results than tokens (defensive) — no crash, no phantom token
expect(selectDeadTokens(['A'], [rej(deadNotRegistered), rej(deadNotRegistered)])).toEqual(['A'])
})
})

View File

@ -0,0 +1,92 @@
import * as admin from 'firebase-admin'
/**
* FCM error codes that mean a token is *permanently* dead and safe to delete.
*
* Deliberately narrow. We only prune on errors that unambiguously blame the TOKEN:
* - `registration-token-not-registered` the app was uninstalled / data-cleared / token rotated.
* - `invalid-registration-token` the token string itself is malformed.
* We intentionally do NOT include `messaging/invalid-argument` or any transient/server error
* (`unavailable`, `internal`, `quota-exceeded`, auth issues): those can be caused by a bad *payload*
* or a temporary outage, and treating them as dead would let one bug wipe every user's tokens.
*/
const DEAD_TOKEN_CODES = new Set<string>([
'messaging/registration-token-not-registered',
'messaging/invalid-registration-token',
])
/** Extract the `messaging/*` code from a firebase-admin messaging rejection, wherever it lives. */
function messagingErrorCode(reason: unknown): string | undefined {
const r = reason as { errorInfo?: { code?: unknown }; code?: unknown } | null | undefined
const code = r?.errorInfo?.code ?? r?.code
return typeof code === 'string' ? code : undefined
}
/** True only when an FCM send rejection means the token is permanently invalid (safe to delete). */
export function isDeadTokenError(reason: unknown): boolean {
const code = messagingErrorCode(reason)
return code !== undefined && DEAD_TOKEN_CODES.has(code)
}
/**
* Pure: given the same `tokens` array and `Promise.allSettled` results a caller already has, return the
* distinct token strings that FCM reported as permanently dead. Index `i` in results maps to `tokens[i]`.
*/
export function selectDeadTokens(
tokens: string[],
results: PromiseSettledResult<unknown>[]
): string[] {
const dead = new Set<string>()
results.forEach((r, i) => {
const token = tokens[i]
if (r.status === 'rejected' && token && isDeadTokenError(r.reason)) dead.add(token)
})
return [...dead]
}
/**
* Best-effort removal of tokens FCM reported as permanently dead. Pass the `tokens` array and the
* `Promise.allSettled` results from the send you just performed; any dead token is deleted from the
* user's `fcmTokens` subcollection and cleared from the legacy `fcmToken` field if it matches.
*
* Never throws pruning is housekeeping and must never fail the notification path it runs after.
* Returns the number of token records removed.
*/
export async function pruneDeadTokens(
db: admin.firestore.Firestore,
uid: string,
tokens: string[],
results: PromiseSettledResult<unknown>[]
): Promise<number> {
const dead = new Set(selectDeadTokens(tokens, results))
if (dead.size === 0) return 0
try {
const userRef = db.collection('users').doc(uid)
const [tokenSnap, userDoc] = await Promise.all([
userRef.collection('fcmTokens').get(),
userRef.get(),
])
const batch = db.batch()
let removed = 0
tokenSnap.docs.forEach((d) => {
const t = d.data()?.token
if (typeof t === 'string' && dead.has(t)) {
batch.delete(d.ref)
removed++
}
})
const legacy = userDoc.data()?.fcmToken
if (typeof legacy === 'string' && dead.has(legacy)) {
batch.update(userRef, { fcmToken: admin.firestore.FieldValue.delete() })
removed++
}
if (removed > 0) {
await batch.commit()
console.log(`[pruneDeadTokens] uid=${uid} removed ${removed} dead token(s)`)
}
return removed
} catch (e) {
console.warn(`[pruneDeadTokens] uid=${uid} prune failed (ignored):`, e)
return 0
}
}