Skip to content
Draft
49 changes: 49 additions & 0 deletions docs/analysis/2026-09-21-notification-store-ownership.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Notification store request and credential ownership

Status: draft PR #3340, 2026-09-21. Base: `307c3b8b50bec1cb0bfaea3e570a942bcb1d4451`.

## Reproduced defects

`notificationStore` previously let every inbox and preference read replace its shared surface. Confirmed mark-read and preference writes did not invalidate reads that began against an older snapshot. Inbox reads, preference reads and preference writes also assigned one loading Boolean directly, so the first settlement could clear another operation's busy state.

The first lifecycle correction treated same-user token refresh as a full data reset and could blank an unchanged inbox or detach the mounted preference form from its store value. The preservation correction then exposed a second boundary: when refresh happened during an empty initial read, the old owner was retired but the unchanged route did not remount or refetch.

## Contract

Inbox and preferences have independent latest-read owners. Each owner carries a unique token, the current lifecycle epoch and the mutation generation observed at request start. A newer request retires only the previous read in the same lane.

Successful `markAsRead` and `markAllRead` advance the inbox mutation generation and retire older inbox reads. A successful preference update does the same for preference reads. Loading is derived from active loading-owner tokens rather than whichever call settles first.

User identity, authentication or demo-session replacement advances the epoch, retires work and clears notifications and preferences.

A token-only rotation:

- preserves settled notifications and preferences;
- suppresses old-token success, failure, toast and loading settlement;
- restarts an active inbox read only while the inbox is still empty;
- restarts an active preference read only while preferences are still null;
- retains the exact inbox query captured by the active read;
- never replays mark-read, mark-all or preference-update mutations.

Mutation serialization, realtime arrival versus refresh, and reminder/email work in #2010 remain outside this slice.

## Test-first evidence

The initial supplemental actual-module suite changed from **0/10 passing on `main`** to **10/10 passing** after the first ownership correction.

Review-regression head `10d48f732725ed8ee9a2557ac39ccf7b7a7958d5` ran canonical Node 24 frontend qualification on Ubuntu and Windows. Lint, typecheck, production build and PWA validation passed on both platforms. Ubuntu JUnit recorded **7,161 tests, exactly 4 failures, 0 errors**, all loaded-state preservation cases.

Issue #3352 added test-only head `660362c9546b51f9996659be3382ac4b6d67f424`, covering token rotation while inbox and preferences are still empty. A dependency-free runner transpiled and executed the actual production module:

- before the retry correction: each read API was called once and loading became false after rotation;
- after the correction: each read API was called twice, old-token settlement was suppressed and fresh-token inbox/preferences populated independently.

Hosted exact-head qualification remains authoritative; the supplemental runner does not replace it.

## Remaining gates

Current production correction: `2192edf4e0717998b7e1c24236546902d6a9229a` before this documentation commit.

Exact final-head lint, typecheck, production build, complete Vitest on Ubuntu and Windows, Required CI, Extended, Self-Test and fresh-context review remain required. Existing notification-store, realtime, integration, demo and view suites must remain green. Stacked preference-order PR #3343 must later be reconciled to this corrected parent and requalified.

This is client-state integrity, not a server-authorization claim or transport cancellation guarantee. No merge, release or deployment qualification is claimed.
262 changes: 226 additions & 36 deletions frontend/taskdeck-web/src/store/notificationStore.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ref, watch } from 'vue'
import { notificationsApi } from '../api/notificationsApi'
import { useToastStore } from './toastStore'
import { useSessionStore } from './sessionStore'
import { isDemoMode, DemoModeError } from '../utils/demoMode'
import { getErrorDisplay } from '../composables/useErrorMapper'
import type {
Expand All @@ -13,12 +14,176 @@ import type {

export const useNotificationStore = defineStore('notifications', () => {
const toast = useToastStore()
const session = useSessionStore()

const notifications = ref<NotificationItem[]>([])
const preferences = ref<NotificationPreference | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)

type ReadLane = 'notifications' | 'preferences'
type ReadRetry = () => Promise<unknown>

interface OperationOwner {
epoch: number
token: symbol
ownsLoading: boolean
}

interface ReadOwner extends OperationOwner {
observedMutationGeneration: number
}

let sessionEpoch = 0
let notificationMutationGeneration = 0
let preferenceMutationGeneration = 0
const activeLoadingOperations = new Set<symbol>()
const readOwners = new Map<ReadLane, ReadOwner>()
const readRetries = new Map<ReadLane, ReadRetry>()

function syncLoading(): void {
loading.value = activeLoadingOperations.size > 0
}

function clearError(): void {
error.value = null
}

function beginOperation(
label: string,
options: { ownsLoading?: boolean; clearExistingError?: boolean } = {},
): OperationOwner {
const owner = {
epoch: sessionEpoch,
token: Symbol(label),
ownsLoading: options.ownsLoading ?? false,
}
if (owner.ownsLoading) activeLoadingOperations.add(owner.token)
if (options.clearExistingError ?? false) clearError()
syncLoading()
return owner
}

function ownsSession(owner: OperationOwner): boolean {
return owner.epoch === sessionEpoch
}

function finishOperation(owner: OperationOwner): void {
if (!ownsSession(owner)) return
if (owner.ownsLoading) activeLoadingOperations.delete(owner.token)
syncLoading()
}

function mutationGeneration(lane: ReadLane): number {
return lane === 'notifications'
? notificationMutationGeneration
: preferenceMutationGeneration
}

function beginRead(lane: ReadLane, retry: ReadRetry): ReadOwner {
const previous = readOwners.get(lane)
if (previous?.epoch === sessionEpoch && previous.ownsLoading) {
activeLoadingOperations.delete(previous.token)
}

const operation = beginOperation(`read:${lane}`, {
ownsLoading: true,
clearExistingError: true,
})
const owner = {
...operation,
observedMutationGeneration: mutationGeneration(lane),
}
readOwners.set(lane, owner)
readRetries.set(lane, retry)
return owner
}

function ownsRead(lane: ReadLane, owner: ReadOwner): boolean {
const current = readOwners.get(lane)
return ownsSession(owner)
&& current?.token === owner.token
&& owner.observedMutationGeneration === mutationGeneration(lane)
}

function finishRead(lane: ReadLane, owner: ReadOwner): void {
if (readOwners.get(lane)?.token === owner.token) {
readOwners.delete(lane)
readRetries.delete(lane)
}
finishOperation(owner)
}

function invalidateRead(lane: ReadLane): void {
const owner = readOwners.get(lane)
if (owner?.epoch === sessionEpoch && owner.ownsLoading) {
activeLoadingOperations.delete(owner.token)
}
readOwners.delete(lane)
readRetries.delete(lane)
syncLoading()
}

function recordNotificationMutation(): void {
notificationMutationGeneration += 1
invalidateRead('notifications')
}

function recordPreferenceMutation(): void {
preferenceMutationGeneration += 1
invalidateRead('preferences')
}

function invalidateOperations(): void {
sessionEpoch += 1
notificationMutationGeneration = 0
preferenceMutationGeneration = 0
activeLoadingOperations.clear()
readOwners.clear()
readRetries.clear()
loading.value = false
clearError()
}

function retryEmptyActiveReads(): void {
const notificationRetry = readOwners.has('notifications') && notifications.value.length === 0
? readRetries.get('notifications')
: undefined
const preferenceRetry = readOwners.has('preferences') && preferences.value === null
? readRetries.get('preferences')
: undefined

invalidateOperations()
if (notificationRetry) {
void notificationRetry().catch(() => {
// The retried store action owns current error/toast state.
})
}
if (preferenceRetry) {
void preferenceRetry().catch(() => {
// The retried store action owns current error/toast state.
})
}
}

function resetForSession(): void {
invalidateOperations()
notifications.value = []
preferences.value = null
}

watch(
() => [session.userId, session.isAuthenticated, session.isDemo],
resetForSession,
{ flush: 'sync' },
)

watch(
() => session.token,
retryEmptyActiveReads,
{ flush: 'sync' },
)

function guardDemoMutation(): never | void {
if (isDemoMode) {
toast.info('This action is view-only in demo mode.')
Expand All @@ -28,46 +193,59 @@ export const useNotificationStore = defineStore('notifications', () => {

async function fetchNotifications(query?: NotificationQuery) {
if (isDemoMode) {
loading.value = true
error.value = null
invalidateRead('notifications')
clearError()
notifications.value = []
loading.value = false
return
}

const owner = beginRead('notifications', () => fetchNotifications(query))
try {
loading.value = true
error.value = null
notifications.value = await notificationsApi.getNotifications(query)
const result = await notificationsApi.getNotifications(query)
if (!ownsRead('notifications', owner)) return
notifications.value = result
} catch (e: unknown) {
const msg = getErrorDisplay(e, 'Failed to load notifications').message
error.value = msg
toast.error(msg)
if (ownsRead('notifications', owner)) {
const msg = getErrorDisplay(e, 'Failed to load notifications').message
error.value = msg
toast.error(msg)
}
throw e
} finally {
loading.value = false
finishRead('notifications', owner)
}
}

async function markAsRead(notificationId: string) {
guardDemoMutation()
const owner = beginOperation(`mark-read:${notificationId}`)
try {
const updated = await notificationsApi.markAsRead(notificationId)
if (!ownsSession(owner)) return updated

recordNotificationMutation()
notifications.value = notifications.value.map((item) => (
item.id === notificationId ? updated : item
))
return updated
} catch (e: unknown) {
const msg = getErrorDisplay(e, 'Failed to mark notification as read').message
error.value = msg
toast.error(msg)
if (ownsSession(owner)) {
const msg = getErrorDisplay(e, 'Failed to mark notification as read').message
error.value = msg
toast.error(msg)
}
throw e
}
}

async function markAllRead(boardId?: string) {
guardDemoMutation()
const owner = beginOperation(`mark-all-read:${boardId ?? 'all'}`)
try {
const result = await notificationsApi.markAllRead(boardId)
if (!ownsSession(owner)) return result

recordNotificationMutation()
notifications.value = notifications.value.map((item) => {
if (boardId && item.boardId !== boardId) return item
return {
Expand All @@ -78,51 +256,63 @@ export const useNotificationStore = defineStore('notifications', () => {
})
return result
} catch (e: unknown) {
const msg = getErrorDisplay(e, 'Failed to mark all notifications as read').message
error.value = msg
toast.error(msg)
if (ownsSession(owner)) {
const msg = getErrorDisplay(e, 'Failed to mark all notifications as read').message
error.value = msg
toast.error(msg)
}
throw e
}
}

async function fetchPreferences() {
if (isDemoMode) {
loading.value = true
error.value = null
invalidateRead('preferences')
clearError()
preferences.value = null
loading.value = false
return preferences.value
}

const owner = beginRead('preferences', fetchPreferences)
try {
loading.value = true
error.value = null
preferences.value = await notificationsApi.getPreferences()
return preferences.value
const result = await notificationsApi.getPreferences()
if (ownsRead('preferences', owner)) preferences.value = result
return result
} catch (e: unknown) {
const msg = getErrorDisplay(e, 'Failed to load notification preferences').message
error.value = msg
toast.error(msg)
if (ownsRead('preferences', owner)) {
const msg = getErrorDisplay(e, 'Failed to load notification preferences').message
error.value = msg
toast.error(msg)
}
throw e
} finally {
loading.value = false
finishRead('preferences', owner)
}
}

async function updatePreferences(dto: UpdateNotificationPreferenceRequest) {
guardDemoMutation()
const owner = beginOperation('update-preferences', {
ownsLoading: true,
clearExistingError: true,
})
try {
loading.value = true
error.value = null
preferences.value = await notificationsApi.updatePreferences(dto)
const updated = await notificationsApi.updatePreferences(dto)
if (!ownsSession(owner)) return updated

recordPreferenceMutation()
preferences.value = updated
toast.success('Notification preferences saved')
return preferences.value
return updated
} catch (e: unknown) {
const msg = getErrorDisplay(e, 'Failed to save notification preferences').message
error.value = msg
toast.error(msg)
if (ownsSession(owner)) {
const msg = getErrorDisplay(e, 'Failed to save notification preferences').message
error.value = msg
toast.error(msg)
}
throw e
} finally {
loading.value = false
finishOperation(owner)
}
}

Expand Down
Loading
Loading