notify: only "the app is gone" deletes a device row

reported from the field on the first real push: it failed with BadDeviceToken
and the server deleted the registration. the build was production-signed and
the row said sandbox — a configuration mismatch, with the device and the app
both perfectly fine.

pruning there destroys the evidence. the phone re-registers on next launch,
the row comes back looking healthy, and the next send fails identically; you
can lose a day to that loop without ever seeing what is actually wrong.

so the one set becomes three. Unregistered/UNREGISTERED/NOT_FOUND prune —
apple and google are saying the app is uninstalled. BadDeviceToken,
DeviceTokenNotForTopic, TopicDisallowed, INVALID_ARGUMENT and
SENDER_ID_MISMATCH are OURS: the row is kept and the log says what to check.
everything else is transient and still counts toward three strikes.

INVALID_ARGUMENT was the sharpest one. it is how fcm reports a malformed
MESSAGE — including a non-string in `data`, which is the mistake this codebase
is most likely to make and already has a test for. deleting on it meant one
bug in the payload builder would wipe every android device from the registry
on a single send, presenting as "android push stopped working" with nothing
left to explain why.

misconfigured is also not counted as a strike. not deleting is not enough on
its own — a persistent mismatch would reach three failures and delete the row
anyway, which is the same bug with a slower fuse.

the apns log now names the mismatch rather than the reason code, since the
code alone sends people to look at the device: it prints the row's environment
and topic and says to check the SHIPPED artefact rather than the intermediate
build, which is exactly where this one hid.
This commit is contained in:
2026-08-19 21:25:20 +00:00
parent c6ef0b34db
commit 5cba1b65a6
19 changed files with 391 additions and 34 deletions
@@ -0,0 +1 @@
export { setChatNotifyPreference, getChatNotifyPreference, getChatNotifyPreferences } from './queries';
@@ -0,0 +1,39 @@
import { inArray } from 'drizzle-orm';
import { db } from '../db';
import { chatNotificationPrefs } from './schema';
import type { NotifyPreference } from '../types';
/** Set a session's notification preference. Upsert — one row per session, last write wins. */
export async function setChatNotifyPreference(sessionId: string, preference: NotifyPreference): Promise<void> {
await db
.insert(chatNotificationPrefs)
.values({ sessionId, preference })
.onConflictDoUpdate({
target: chatNotificationPrefs.sessionId,
set: { preference, updatedAt: new Date() },
});
}
/** One session's stored preference, or `null` when nothing has been set yet (treat as `'none'`). */
export async function getChatNotifyPreference(sessionId: string): Promise<NotifyPreference | null> {
const [row] = await db
.select({ preference: chatNotificationPrefs.preference })
.from(chatNotificationPrefs)
.where(inArray(chatNotificationPrefs.sessionId, [sessionId]))
.limit(1);
return (row?.preference as NotifyPreference | undefined) ?? null;
}
/**
* Stored preferences for a page of sessions, as a Map. Batched on purpose — the session list renders
* this per row, and the per-row alternative is one query per session. Returns an empty Map for an empty
* input rather than issuing `IN ()`, which Postgres rejects.
*/
export async function getChatNotifyPreferences(sessionIds: string[]): Promise<Map<string, NotifyPreference>> {
if (!sessionIds.length) return new Map();
const rows = await db
.select({ sessionId: chatNotificationPrefs.sessionId, preference: chatNotificationPrefs.preference })
.from(chatNotificationPrefs)
.where(inArray(chatNotificationPrefs.sessionId, sessionIds));
return new Map(rows.map((r) => [r.sessionId, r.preference as NotifyPreference]));
}
@@ -0,0 +1,30 @@
import { pgTable, text, timestamp, check } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
// Whether a chat session should tell you about itself — the bell menu on a chat's header.
//
// There is no `chat_sessions` table (a session IS its transcript, per `chat-session-models/schema.ts`),
// so this claims only the field the transcript cannot express, keyed the same way that table is.
const NOTIFY_PREFERENCES = ['every_turn', 'input_needed', 'none'] as const;
export const chatNotificationPrefs = pgTable(
'chat_notification_prefs',
{
/** The session's transcript uuid — same natural key as `chat_session_models.sessionId`. */
sessionId: text('session_id').primaryKey(),
preference: text('preference').notNull().default('none'),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
// sql.raw per value — an interpolated string binds as a parameter, which Postgres refuses inside a
// CHECK and breaks push for the whole schema. See src/databases/CLAUDE.md.
check(
'ck_chat_notification_prefs_preference',
sql`${table.preference} IN (${sql.join(
NOTIFY_PREFERENCES.map((p) => sql.raw(`'${p}'`)),
sql`, `,
)})`,
),
],
);
+1
View File
@@ -34,6 +34,7 @@ export * from './auth';
export * from './permissions';
export * from './chat-events';
export * from './chat-session-models';
export * from './chat-notification-prefs';
export * from './ai-provider-usage';
export * from './command-hq';
export * from './dashboards';
+1
View File
@@ -32,6 +32,7 @@ export * from './server/schema'; // server_config (SMTP lives here), server_inte
export * from './pipeline-jobs/schema'; // pipeline_jobs
export * from './chat-events/schema'; // chat_session_events
export * from './chat-session-models/schema'; // chat_session_models
export * from './chat-notification-prefs/schema'; // chat_notification_prefs
export * from './ai-provider-usage/schema'; // ai_provider_usage — last usage reading per AI account
export * from './command-hq/schema'; // command_hq_categories, command_hq_sessions, command_hq_tags, command_hq_session_tags
export * from './agent-panels/schema'; // agent_panels
+8
View File
@@ -52,6 +52,14 @@ export type DockConfigInsert = typeof Schema.dockConfigs.$inferInsert;
// ── Saved Sessions ──
/** Kept as a literal union, not inferred from the `text` column — the schema's CHECK is the only thing
* that actually enforces this set; this is what lets every other layer (queries, routes, client) agree
* with it instead of each re-typing the three strings. */
export type NotifyPreference = 'every_turn' | 'input_needed' | 'none';
export type ChatNotificationPrefSelect = typeof Schema.chatNotificationPrefs.$inferSelect;
export type ChatNotificationPrefInsert = typeof Schema.chatNotificationPrefs.$inferInsert;
// ── Dashboards ──
export type DashboardSelect = typeof Schema.dashboards.$inferSelect;