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:
@@ -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`, `,
|
||||
)})`,
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user