Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions docs/analysis/2026-09-21-notification-preference-order.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Notification preference mutation ordering

Status: stacked draft PR #3343, 2026-09-21. Parent: PR #3340.

## Reproduced defect

Every `updatePreferences` call previously started transport immediately. The API carries no expected revision and the persisted notification-preference row has no configured concurrency token. Two saves from one client could therefore commit or settle in an order different from the user’s submissions.

A queued correction also needs explicit error ownership. An independent inbox read may fail while the next preference save waits; starting that queued save must not erase the unrelated inbox receipt.

## Contract

- One preference-mutation lane preserves this client’s submission order.
- The first save starts transport synchronously. Later saves wait for their predecessor, regardless of success or failure.
- Every queued save owns a loading token from submission through settlement.
- Immediately before transport, queued work rechecks the credential epoch inherited from #3340. Token, identity, authentication or demo replacement clears queue registration and prevents old intent from running with later credentials.
- Error receipts carry the operation token that produced them. Queued start retires only its own predecessor’s receipt; an independent inbox failure remains visible.
- Successful saves retain #3340’s preference-read invalidation.

This preserves one client’s order only. It does not claim cross-device concurrency safety or add a server-side revision precondition.

## Test-first evidence

Test-only child head: `4f89c6f2969d5dbad923841b4984ef152daef824`.

A supplemental runner transpiled and executed the actual parent and corrected production stores with framework/API/session boundaries stubbed:

- parent #3340: **1/5 passed**; only the existing loading-token control passed;
- corrected child: **5/5 passed**.

The four parent failures demonstrated eager second transport, failed-predecessor overlap, queued old-credential transport, and lack of a real queued boundary for independent-error preservation.

The committed Pinia suite covers immediate first transport, serialization, failed-predecessor continuation, token replacement, queued loading, and preservation of an independent inbox failure.

## Verification and remaining gates

The corrected module transpiles under TypeScript 5.8.3 with zero diagnostics. Exact-head Pinia/Vitest, lint, project typecheck, build, full hosted CI and independent review remain required. After #3340 lands, retarget to current `main`, verify the child-only diff and requalify.

No merge, release or deployment qualification is claimed by this note.
64 changes: 46 additions & 18 deletions frontend/taskdeck-web/src/store/notificationStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,16 @@ export const useNotificationStore = defineStore('notifications', () => {
observedMutationGeneration: number
}

interface PreferenceMutationTail {
promise: Promise<void>
ownerToken: symbol
}

let sessionEpoch = 0
let notificationMutationGeneration = 0
let preferenceMutationGeneration = 0
let errorOwner: symbol | null = null
let preferenceMutationTail: PreferenceMutationTail | null = null
const activeLoadingOperations = new Set<symbol>()
const readOwners = new Map<ReadLane, ReadOwner>()

Expand All @@ -45,6 +52,12 @@ export const useNotificationStore = defineStore('notifications', () => {

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

function recordError(owner: OperationOwner, message: string): void {
error.value = message
errorOwner = owner.token
}

function beginOperation(
Expand Down Expand Up @@ -133,6 +146,7 @@ export const useNotificationStore = defineStore('notifications', () => {
preferenceMutationGeneration = 0
activeLoadingOperations.clear()
readOwners.clear()
preferenceMutationTail = null
notifications.value = []
preferences.value = null
loading.value = false
Expand Down Expand Up @@ -168,7 +182,7 @@ export const useNotificationStore = defineStore('notifications', () => {
} catch (e: unknown) {
if (ownsRead('notifications', owner)) {
const msg = getErrorDisplay(e, 'Failed to load notifications').message
error.value = msg
recordError(owner, msg)
toast.error(msg)
}
throw e
Expand All @@ -192,7 +206,7 @@ export const useNotificationStore = defineStore('notifications', () => {
} catch (e: unknown) {
if (ownsSession(owner)) {
const msg = getErrorDisplay(e, 'Failed to mark notification as read').message
error.value = msg
recordError(owner, msg)
toast.error(msg)
}
throw e
Expand All @@ -219,7 +233,7 @@ export const useNotificationStore = defineStore('notifications', () => {
} catch (e: unknown) {
if (ownsSession(owner)) {
const msg = getErrorDisplay(e, 'Failed to mark all notifications as read').message
error.value = msg
recordError(owner, msg)
toast.error(msg)
}
throw e
Expand All @@ -242,7 +256,7 @@ export const useNotificationStore = defineStore('notifications', () => {
} catch (e: unknown) {
if (ownsRead('preferences', owner)) {
const msg = getErrorDisplay(e, 'Failed to load notification preferences').message
error.value = msg
recordError(owner, msg)
toast.error(msg)
}
throw e
Expand All @@ -253,27 +267,41 @@ export const useNotificationStore = defineStore('notifications', () => {

async function updatePreferences(dto: UpdateNotificationPreferenceRequest) {
guardDemoMutation()
const predecessor = preferenceMutationTail
const owner = beginOperation('update-preferences', {
ownsLoading: true,
clearExistingError: true,
clearExistingError: predecessor === null,
})
try {
const updated = await notificationsApi.updatePreferences(dto)
if (!ownsSession(owner)) return updated
let release!: () => void
const tail = new Promise<void>((resolve) => { release = resolve })
preferenceMutationTail = { promise: tail, ownerToken: owner.token }

recordPreferenceMutation()
preferences.value = updated
toast.success('Notification preferences saved')
return updated
} catch (e: unknown) {
if (ownsSession(owner)) {
const msg = getErrorDisplay(e, 'Failed to save notification preferences').message
error.value = msg
toast.error(msg)
try {
if (predecessor) await predecessor.promise
if (!ownsSession(owner)) return undefined

if (predecessor && errorOwner === predecessor.ownerToken) clearError()

try {
const updated = await notificationsApi.updatePreferences(dto)
if (!ownsSession(owner)) return updated

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

Expand Down
Loading
Loading