Skip to content

Commit 06ca195

Browse files
peppescgclaude
andcommitted
fix(newsletter): stop dismissal re-pop loop and add DB write observability
The newsletter modal re-pops because the dismissal is never persisted when the SQLite write fails silently. First step of #2321: - setNewsletterDismissedAt now reports whether the dismissal was actually persisted. The renderer suppresses the modal for the rest of the session only when it wasn't — stopping the in-session re-pop loop on a read-only DB or a failed write, while leaving the normal (persisted) flow unchanged. - Instrument DB write failures in Sentry, separating Bucket A (read-only DB: on-disk schema newer than the app, captured in the migrator with applied vs known schema) from Bucket B (the write threw, captured with the SQLite error code). os.name / release are attached automatically. Today these failures are invisible — Bucket A doesn't even produce a local log. Refs #2321 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0be5645 commit 06ca195

9 files changed

Lines changed: 143 additions & 28 deletions

File tree

main/src/db/migrator.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type Database from 'better-sqlite3'
22
import { getDb, setDbWritable } from './database'
33
import log from '../logger'
4-
import { withDbSpan } from './telemetry'
4+
import { withDbSpan, captureDbReadOnly } from './telemetry'
55

66
interface Migration {
77
id: number
@@ -58,6 +58,7 @@ export function runMigrations(): void {
5858
`[DB] Database schema (v${highestApplied}) is newer than app (v${highestKnown}). ` +
5959
'Skipping SQLite writes to avoid corruption.'
6060
)
61+
captureDbReadOnly(highestApplied, highestKnown)
6162
setDbWritable(false)
6263
return
6364
}

main/src/db/telemetry.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,44 @@ export function withDbSpan<T>(
3232
}
3333
)
3434
}
35+
36+
// Bucket A: the on-disk schema is newer than the running app, so the migrator
37+
// disabled all writes (see migrator.ts). Reported once per launch on affected
38+
// installs. os.name / release are attached automatically by @sentry/electron.
39+
export function captureDbReadOnly(
40+
appliedSchema: number,
41+
knownSchema: number
42+
): void {
43+
Sentry.withScope((scope) => {
44+
scope.setTag('db.failure_bucket', 'schema_newer')
45+
scope.setExtras({
46+
'db.applied_schema': appliedSchema,
47+
'db.known_schema': knownSchema,
48+
})
49+
Sentry.captureMessage(
50+
`[DB] Read-only: on-disk schema v${appliedSchema} is newer than app v${knownSchema}; SQLite writes disabled`,
51+
'warning'
52+
)
53+
})
54+
}
55+
56+
// Bucket B: a SQLite write actually threw (filesystem/permission/lock/disk/
57+
// corruption). The native `code` (e.g. SQLITE_IOERR, SQLITE_FULL, SQLITE_BUSY)
58+
// is surfaced as a tag so we can tell the failure modes apart.
59+
export function captureDbWriteFailure(
60+
table: string,
61+
key: string,
62+
error: unknown
63+
): void {
64+
const code =
65+
typeof error === 'object' && error !== null && 'code' in error
66+
? String((error as { code: unknown }).code)
67+
: undefined
68+
Sentry.withScope((scope) => {
69+
scope.setTag('db.failure_bucket', 'write_threw')
70+
scope.setTag('db.table', table)
71+
if (code) scope.setTag('db.sqlite_code', code)
72+
scope.setExtras({ 'db.key': key })
73+
Sentry.captureException(error)
74+
})
75+
}

main/src/db/writers/settings-writer.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
import { getDb, isDbWritable } from '../database'
2-
import { withDbSpan } from '../telemetry'
2+
import { withDbSpan, captureDbWriteFailure } from '../telemetry'
33

44
export function writeSetting(key: string, value: string): void {
55
if (!isDbWritable()) return
6-
withDbSpan('DB write setting', 'db.write', { 'db.key': key }, () => {
7-
const db = getDb()
8-
db.prepare(
9-
'INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)'
10-
).run(key, value)
11-
})
6+
try {
7+
withDbSpan('DB write setting', 'db.write', { 'db.key': key }, () => {
8+
const db = getDb()
9+
db.prepare(
10+
'INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)'
11+
).run(key, value)
12+
})
13+
} catch (err) {
14+
captureDbWriteFailure('settings', key, err)
15+
throw err
16+
}
1217
}
1318

1419
export function deleteSetting(key: string): void {

main/src/newsletter.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Store from 'electron-store'
22
import log from './logger'
3+
import { isDbWritable } from './db/database'
34
import { writeSetting } from './db/writers/settings-writer'
45
import { readSetting } from './db/readers/settings-reader'
56

@@ -50,10 +51,16 @@ export function setNewsletterSubscribed(subscribed: boolean): void {
5051
}
5152
}
5253

53-
export function setNewsletterDismissedAt(dismissedAt: string): void {
54+
// Returns whether the dismissal was actually persisted. `false` means the
55+
// write was skipped (read-only DB — Bucket A) or threw (Bucket B); the renderer
56+
// uses this to suppress the modal for the session so it doesn't loop.
57+
export function setNewsletterDismissedAt(dismissedAt: string): boolean {
58+
if (!isDbWritable()) return false
5459
try {
5560
writeSetting('newsletterDismissedAt', dismissedAt)
61+
return true
5662
} catch (err) {
5763
log.error('[DB] Failed to write newsletterDismissedAt:', err)
64+
return false
5865
}
5966
}

main/src/tests/newsletter.test.ts

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import { describe, it, expect, vi, beforeEach } from 'vitest'
22

3-
const { mockReadSetting, mockWriteSetting } = vi.hoisted(() => ({
4-
mockReadSetting: vi.fn(),
5-
mockWriteSetting: vi.fn(),
6-
}))
3+
const { mockReadSetting, mockWriteSetting, mockIsDbWritable } = vi.hoisted(
4+
() => ({
5+
mockReadSetting: vi.fn(),
6+
mockWriteSetting: vi.fn(),
7+
mockIsDbWritable: vi.fn(() => true),
8+
})
9+
)
710

811
vi.mock('@sentry/electron/main', () => ({
912
startSpan: vi.fn((_opts: unknown, cb: (span: unknown) => unknown) =>
@@ -28,6 +31,10 @@ vi.mock('../db/readers/settings-reader', () => ({
2831
readSetting: mockReadSetting,
2932
}))
3033

34+
vi.mock('../db/database', () => ({
35+
isDbWritable: mockIsDbWritable,
36+
}))
37+
3138
vi.mock('electron-store', () => ({
3239
default: vi.fn(function ElectronStore() {
3340
return { get: vi.fn(), set: vi.fn() }
@@ -42,7 +49,10 @@ import {
4249

4350
describe('newsletter', () => {
4451
beforeEach(() => {
45-
vi.clearAllMocks()
52+
// resetAllMocks (not just clearAllMocks) so a throwing writeSetting/readSetting
53+
// implementation set by one test doesn't leak into the next.
54+
vi.resetAllMocks()
55+
mockIsDbWritable.mockReturnValue(true)
4656
})
4757

4858
describe('getNewsletterState', () => {
@@ -100,24 +110,36 @@ describe('newsletter', () => {
100110
})
101111

102112
describe('setNewsletterDismissedAt', () => {
103-
it('writes to SQLite', () => {
113+
it('writes to SQLite and returns true on success', () => {
104114
const timestamp = '2026-03-18T10:00:00.000Z'
105-
setNewsletterDismissedAt(timestamp)
115+
const persisted = setNewsletterDismissedAt(timestamp)
106116

107117
expect(mockWriteSetting).toHaveBeenCalledWith(
108118
'newsletterDismissedAt',
109119
timestamp
110120
)
121+
expect(persisted).toBe(true)
111122
})
112123

113-
it('does not throw when SQLite write fails', () => {
124+
it('returns false and skips the write when the DB is read-only', () => {
125+
mockIsDbWritable.mockReturnValue(false)
126+
127+
const persisted = setNewsletterDismissedAt('2026-03-18T10:00:00.000Z')
128+
129+
expect(persisted).toBe(false)
130+
expect(mockWriteSetting).not.toHaveBeenCalled()
131+
})
132+
133+
it('returns false (and does not throw) when the SQLite write fails', () => {
114134
mockWriteSetting.mockImplementation(() => {
115135
throw new Error('DB write failed')
116136
})
117137

118-
expect(() =>
119-
setNewsletterDismissedAt('2026-03-18T10:00:00.000Z')
120-
).not.toThrow()
138+
let persisted: boolean | undefined
139+
expect(() => {
140+
persisted = setNewsletterDismissedAt('2026-03-18T10:00:00.000Z')
141+
}).not.toThrow()
142+
expect(persisted).toBe(false)
121143
})
122144
})
123145
})

preload/src/api/app.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export const appApi = {
3131
}> => ipcRenderer.invoke('get-newsletter-state'),
3232
setNewsletterSubscribed: (subscribed: boolean): Promise<void> =>
3333
ipcRenderer.invoke('set-newsletter-subscribed', subscribed),
34-
setNewsletterDismissedAt: (dismissedAt: string): Promise<void> =>
34+
setNewsletterDismissedAt: (dismissedAt: string): Promise<boolean> =>
3535
ipcRenderer.invoke('set-newsletter-dismissed-at', dismissedAt),
3636

3737
getExpertConsultationState: (): Promise<{
@@ -66,7 +66,7 @@ export interface AppAPI {
6666
dismissedAt: string
6767
}>
6868
setNewsletterSubscribed: (subscribed: boolean) => Promise<void>
69-
setNewsletterDismissedAt: (dismissedAt: string) => Promise<void>
69+
setNewsletterDismissedAt: (dismissedAt: string) => Promise<boolean>
7070
getExpertConsultationState: () => Promise<{
7171
submitted: boolean
7272
dismissedAt: string

renderer/src/common/components/__tests__/newsletter-modal.test.tsx

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ describe('NewsletterModal', () => {
6060
.mockResolvedValue(undefined)
6161
window.electronAPI.setNewsletterDismissedAt = vi
6262
.fn()
63-
.mockResolvedValue(undefined)
63+
.mockResolvedValue(true)
6464

6565
server.use(
6666
http.post(HUBSPOT_URL, () =>
@@ -294,5 +294,38 @@ describe('NewsletterModal', () => {
294294
.mock.calls[0]?.[0]
295295
expect(new Date(calledWith!).getTime()).not.toBeNaN()
296296
})
297+
298+
it('does not re-show the modal in the same session when the dismissal cannot be persisted', async () => {
299+
const title = `Stay up to date with improvements to ${APP_DISPLAY_NAME}`
300+
// Read-only DB: the write never persists, so getNewsletterState keeps
301+
// returning an empty dismissedAt across refetches.
302+
window.electronAPI.setNewsletterDismissedAt = vi
303+
.fn()
304+
.mockResolvedValue(false)
305+
window.electronAPI.getNewsletterState = vi
306+
.fn()
307+
.mockResolvedValue({ subscribed: false, dismissedAt: '' })
308+
309+
renderWithProviders(<NewsletterModal />)
310+
311+
await waitFor(() => {
312+
expect(screen.getByText(title)).toBeVisible()
313+
})
314+
315+
const initialStateCalls = vi.mocked(window.electronAPI.getNewsletterState)
316+
.mock.calls.length
317+
318+
await userEvent.click(screen.getByRole('button', { name: /close/i }))
319+
320+
// The dismiss triggers an invalidate -> refetch of the newsletter state.
321+
await waitFor(() => {
322+
expect(
323+
vi.mocked(window.electronAPI.getNewsletterState).mock.calls.length
324+
).toBeGreaterThan(initialStateCalls)
325+
})
326+
327+
// Even though the dismissal wasn't persisted, the modal must stay gone.
328+
expect(screen.queryByText(title)).not.toBeInTheDocument()
329+
})
297330
})
298331
})

renderer/src/common/components/newsletter-modal.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ function NewsletterDialog({
3535
}: {
3636
successMessage: string
3737
onSubscribed: (message: string) => void
38-
onDismiss: () => void
38+
onDismiss: (persisted: boolean) => void
3939
onClose: () => void
4040
}) {
4141
const [email, setEmail] = useState('')
@@ -76,9 +76,9 @@ function NewsletterDialog({
7676
const { mutate: dismiss } = useMutation({
7777
mutationFn: () =>
7878
window.electronAPI.setNewsletterDismissedAt(new Date().toISOString()),
79-
onSuccess: () => {
79+
onSuccess: (persisted) => {
8080
trackEvent('Newsletter dismissed')
81-
onDismiss()
81+
onDismiss(persisted)
8282
},
8383
})
8484

@@ -214,7 +214,13 @@ export function NewsletterModal() {
214214
<NewsletterDialog
215215
successMessage={successMessage}
216216
onSubscribed={(message) => setSuccessMessage(message)}
217-
onDismiss={() => {
217+
onDismiss={(persisted) => {
218+
// Only when the dismissal could NOT be persisted (read-only DB — Bucket
219+
// A — or a failed write — Bucket B): hide for the rest of the session,
220+
// otherwise the invalidate->refetch below returns an empty dismissedAt
221+
// and immediately re-shows the modal, looping within the session.
222+
// When persistence works, the stored state handles suppression.
223+
if (!persisted) setClosed(true)
218224
closeNewsletterModal()
219225
queryClient.invalidateQueries({ queryKey: ['newsletter-state'] })
220226
}}

renderer/src/common/mocks/electronAPI.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ function createElectronStub(): Partial<ElectronAPI> {
1515
.fn()
1616
.mockResolvedValue({ subscribed: false, dismissedAt: '' }),
1717
setNewsletterSubscribed: vi.fn().mockResolvedValue(undefined),
18-
setNewsletterDismissedAt: vi.fn().mockResolvedValue(undefined),
18+
setNewsletterDismissedAt: vi.fn().mockResolvedValue(true),
1919
getExpertConsultationState: vi
2020
.fn()
2121
.mockResolvedValue({ submitted: false, dismissedAt: '' }),

0 commit comments

Comments
 (0)