feat(concurrency): echo the base token from the prefs write clients and document it

Wire the notification-prefs cards, the module toggles, the coach-preferences
save, and the two mood-tag manage cards to echo the optimistic-concurrency
token they last read and to reload on a 409 instead of resaving over a newer
change. Document the token and the 409 on the four write endpoints in the
OpenAPI registry via the shared base-token field and conflict-response
fragments.
This commit is contained in:
Marc-André Bombeck committed 2026-07-24 18:07:43 +02:00
1 parent 8849f2cecd
commit 3f33a30415
12 files changed
+352 -44

No files matched your search

+28 -1
View File
@@ -59,6 +59,12 @@ import {
apiPost,
apiPut,
} from "@/lib/api/api-fetch";
import {
readUpdatedAtToken,
withBaseToken,
isConflict,
} from "@/lib/api/optimistic-token";
import { queryKeys } from "@/lib/query-keys";
import { reorderById } from "@/lib/insights-layout-reorder";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { useTranslations } from "@/lib/i18n/context";
@@ -72,6 +78,7 @@ import {
snapshotManageCache,
updateManageCache,
type ManageCatalog,
type MoodTagLayoutResolved,
} from "./use-mood-tag-manage";
/**
@@ -141,11 +148,31 @@ export function TagGroupsCard({ catalog }: TagGroupsCardProps) {
reorderGroups(current, nextOrder),
);
try {
await apiPut("/api/mood/tags/layout", { groupOrder: nextOrder });
// v1.32.22 (R5b) — echo the base token this order was based on. The two
// cards on this page write DIFFERENT fields of the same blob (group order
// vs tag placements); a stale merge would resurrect the other card's
// overwritten field, which the token now 409s instead. Seed the token
// from the response so the next write on this surface is guarded too.
const resolved = await apiPut<MoodTagLayoutResolved>(
"/api/mood/tags/layout",
withBaseToken(
{ groupOrder: nextOrder },
readUpdatedAtToken(queryClient, queryKeys.moodTagLayout()),
),
);
queryClient.setQueryData(queryKeys.moodTagLayout(), resolved);
toast.success(t("mood.manage.orderSaved"));
return true;
} catch (err) {
rollback();
if (isConflict(err)) {
// Drop the stale token so a deliberate retry writes against the tree
// the invalidation below reloads; nudge — the optimistic delta rolled
// back and nothing was clobbered.
queryClient.removeQueries({ queryKey: queryKeys.moodTagLayout() });
toast.message(t("common.conflictReloaded"));
return false;
}
toast.error(err instanceof ApiError ? err.message : t("common.error"));
return false;
} finally {
@@ -47,6 +47,12 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ApiError, apiPatch, apiPut } from "@/lib/api/api-fetch";
import {
readUpdatedAtToken,
withBaseToken,
isConflict,
} from "@/lib/api/optimistic-token";
import { queryKeys } from "@/lib/query-keys";
import { reorderById } from "@/lib/insights-layout-reorder";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { useTranslations } from "@/lib/i18n/context";
@@ -65,6 +71,7 @@ import {
type ManageCatalog,
type ManageCategory,
type ManageTag,
type MoodTagLayoutResolved,
} from "./use-mood-tag-manage";
/**
@@ -139,13 +146,26 @@ export function TagManagerCard({ catalog }: TagManagerCardProps) {
const rollback = await snapshotManageCache(queryClient);
updateManageCache(queryClient, () => mergeBack(catalog, nextCatalog));
try {
await apiPut("/api/mood/tags/layout", {
placements: buildVisiblePlacements(nextCatalog),
});
// v1.32.22 (R5b) — echo + seed the layout token (see `putGroupOrder` in
// the groups card): the sibling group-order card writes the same blob, so
// a stale placements merge is exactly the overwrite the token 409s.
const resolved = await apiPut<MoodTagLayoutResolved>(
"/api/mood/tags/layout",
withBaseToken(
{ placements: buildVisiblePlacements(nextCatalog) },
readUpdatedAtToken(queryClient, queryKeys.moodTagLayout()),
),
);
queryClient.setQueryData(queryKeys.moodTagLayout(), resolved);
toast.success(t("mood.manage.orderSaved"));
return true;
} catch (err) {
rollback();
if (isConflict(err)) {
queryClient.removeQueries({ queryKey: queryKeys.moodTagLayout() });
toast.message(t("common.conflictReloaded"));
return false;
}
toast.error(err instanceof ApiError ? err.message : t("common.error"));
return false;
} finally {
@@ -237,12 +257,24 @@ export function TagManagerCard({ catalog }: TagManagerCardProps) {
}
// Both kinds pin the resulting per-group order in the layout blob
// so the resolved tree matches what the user just saw happen.
await apiPut("/api/mood/tags/layout", {
placements: buildVisiblePlacements(moved),
});
// v1.32.22 (R5b) — echo + seed the layout token so a concurrent write to
// the same blob 409s instead of clobbering.
const resolved = await apiPut<MoodTagLayoutResolved>(
"/api/mood/tags/layout",
withBaseToken(
{ placements: buildVisiblePlacements(moved) },
readUpdatedAtToken(queryClient, queryKeys.moodTagLayout()),
),
);
queryClient.setQueryData(queryKeys.moodTagLayout(), resolved);
toast.success(t("common.saved"));
} catch (err) {
rollback();
if (isConflict(err)) {
queryClient.removeQueries({ queryKey: queryKeys.moodTagLayout() });
toast.message(t("common.conflictReloaded"));
return;
}
toast.error(err instanceof ApiError ? err.message : t("common.error"));
} finally {
void invalidateMoodTagCaches(queryClient);
@@ -62,6 +62,19 @@ export interface ManageCatalog {
categories: ManageCategory[];
}
/**
* v1.32.22 (R5b) — the resolved `GET/PUT /api/mood/tags/layout` response.
* `updatedAt` is the optimistic-concurrency token the two management cards
* echo on their next write; no active query reads this key (it is only ever
* invalidated), so the token is seeded straight from each write response into
* `queryKeys.moodTagLayout()` and read back at mutate time.
*/
export interface MoodTagLayoutResolved {
groupOrder: string[];
placements: Record<string, string[]>;
updatedAt?: string;
}
export function useMoodTagManage(enabled: boolean) {
return useQuery({
queryKey: queryKeys.moodTagManage(),
+24 -1
View File
@@ -26,6 +26,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { readUpdatedAtToken, withBaseToken } from "@/lib/api/optimistic-token";
interface CoachPrefsShape {
nudgesEnabled: boolean;
@@ -39,6 +40,9 @@ interface CoachPrefsShape {
interface NotificationPrefsShape {
coach: CoachPrefsShape;
// v1.32.22 (R5b) — optimistic-concurrency token, a sibling key on the
// resolved prefs response, echoed on every PATCH from this card.
updatedAt?: string;
}
const COACH_PREF_DEFAULTS: CoachPrefsShape = {
@@ -140,10 +144,18 @@ export function CoachNudgeCard({
clearTimerRef.current = null;
}
// v1.32.22 (R5b) — echo the base token so a concurrent notification-prefs
// write (the iOS client, another web card) 409s instead of reverting a
// sibling category through the server-side deep-merge.
const res = await apiFetchRaw("/api/auth/me/notification-prefs", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ coach: partial }),
body: JSON.stringify(
withBaseToken(
{ coach: partial },
readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()),
),
),
});
if (res.ok) {
@@ -156,6 +168,17 @@ export function CoachNudgeCard({
queryKey: queryKeys.authNotificationPrefs(),
});
setOptimistic({});
} else if (res.status === 409) {
// The prefs advanced since they were loaded. Revert the optimistic knob
// and refetch (the active query reseeds a fresh token), then nudge — the
// change did not persist, so ask the user to re-apply it.
setOptimistic({});
await queryClient.invalidateQueries({
queryKey: queryKeys.authNotificationPrefs(),
});
setMsg(t("common.conflictReloaded"));
setMsgType("error");
scheduleClear();
} else {
setOptimistic({});
setMsg(t("notifications.coachNudge.saveError"));
@@ -93,6 +93,12 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
onSuccess: () => {
toast.success(t("insights.coach.settingsSaved"));
},
// v1.32.22 (R5b) — a concurrent write advanced the row since this edit was
// based. The hook already refetched (so the form re-syncs to server truth
// and the token advances); nudge the user to re-apply and save again.
onConflict: () => {
toast.message(t("common.conflictReloaded"));
},
});
function setExcluded(metric: CoachExcludeMetric, excluded: boolean) {
+22 -1
View File
@@ -12,6 +12,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { readUpdatedAtToken, withBaseToken } from "@/lib/api/optimistic-token";
/**
* v1.16.11 — medication low-stock alert threshold (Settings →
@@ -26,6 +27,9 @@ import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
interface NotificationPrefsShape {
medication: { lowStockRunwayDays: number | null; reorderLeadDays?: number };
// v1.32.22 (R5b) — optimistic-concurrency token, a sibling key on the
// resolved prefs response, echoed on every PATCH from this card.
updatedAt?: string;
}
const DEFAULT_DAYS = 7;
@@ -116,10 +120,18 @@ export function LowStockCard({
clearTimerRef.current = null;
}
// v1.32.22 (R5b) — echo the base token so a concurrent notification-prefs
// write (the iOS client, another web card) 409s instead of reverting a
// sibling category through the server-side deep-merge.
const res = await apiFetchRaw("/api/auth/me/notification-prefs", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
body: JSON.stringify(
withBaseToken(
body,
readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()),
),
),
});
if (res.ok) {
@@ -133,6 +145,15 @@ export function LowStockCard({
queryKey: queryKeys.settingsReminderThresholds(),
}),
]);
} else if (res.status === 409) {
// The prefs advanced since they were loaded. Refetch (the active query
// reseeds a fresh token) and nudge — the caller reverts its optimistic
// value, so the card snaps back to server truth for a re-apply.
await queryClient.invalidateQueries({
queryKey: queryKeys.authNotificationPrefs(),
});
setMsg(t("common.conflictReloaded"));
setMsgType("error");
} else {
setMsg(t("notifications.lowStock.saveError"));
setMsgType("error");
+43 -2
View File
@@ -65,6 +65,11 @@ import { useAuth } from "@/hooks/use-auth";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useTranslations } from "@/lib/i18n/context";
import { apiPatch } from "@/lib/api/api-fetch";
import {
readUpdatedAtToken,
withBaseToken,
isConflict,
} from "@/lib/api/optimistic-token";
import { queryKeys } from "@/lib/query-keys";
import {
MODULE_REGISTRY,
@@ -73,6 +78,12 @@ import {
type ModuleKey,
} from "@/lib/modules/registry";
/** Shape of the `/api/auth/me/modules` PATCH response (resolved map + token). */
type ModulesPatchResult = {
modules: Partial<Record<ModuleKey, boolean>>;
updatedAt?: string;
};
/** Neutral Lucide glyph per toggleable module. */
const MODULE_ICONS: Record<ModuleKey, LucideIcon> = {
cycle: CalendarHeart,
@@ -130,7 +141,26 @@ export function ModulesSection() {
return apiPatch("/api/auth/me/cycle-prefs", { enabled: vars.enabled });
}
// DISABLED allowlist: send only the single key the user flipped.
return apiPatch("/api/auth/me/modules", { [vars.key]: vars.enabled });
//
// v1.32.22 (R5b) — echo the optimistic-concurrency token so an
// interleaved modules PATCH (another tab, the iOS client) 409s instead of
// clobbering. The token rides on `User.updatedAt`; the module map itself
// rides on `/auth/me`, and no dedicated GET populates the token, so it is
// seeded from each write response below and read back here at mutate
// time. The first write of a session is tokenless — the server's
// backward-compatible unconditional arm.
const result = await apiPatch<ModulesPatchResult>(
"/api/auth/me/modules",
withBaseToken(
{ [vars.key]: vars.enabled },
readUpdatedAtToken(queryClient, queryKeys.modulesPrefs()),
),
);
queryClient.setQueryData<{ updatedAt?: string }>(
queryKeys.modulesPrefs(),
{ updatedAt: result.updatedAt },
);
return result;
},
onSuccess: (_data, vars) => {
// Re-gate every surface that reads the module map off /auth/me: nav,
@@ -146,7 +176,18 @@ export function ModulesSection() {
}
toast.success(t("settings.sections.modules.saved"));
},
onError: () => {
onError: (err) => {
// v1.32.22 (R5b) — instant-write disposition: a 409 means the module map
// advanced since this switch was based (another tab / the iOS client
// wrote in between). Drop the now-stale seeded token and refetch /auth/me
// so the switch snaps back to server truth, then nudge — nothing was
// clobbered. A retry re-seeds a fresh token from its own response.
if (isConflict(err)) {
queryClient.removeQueries({ queryKey: queryKeys.modulesPrefs() });
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
toast.message(t("common.conflictReloaded"));
return;
}
toast.error(t("settings.sections.modules.error"));
},
});
+24 -1
View File
@@ -11,6 +11,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { readUpdatedAtToken, withBaseToken } from "@/lib/api/optimistic-token";
interface ProfileShape {
moodReminderEnabled: boolean;
@@ -18,6 +19,9 @@ interface ProfileShape {
interface NotificationPrefsShape {
mood: { reminderHour: number };
// v1.32.22 (R5b) — optimistic-concurrency token, a sibling key on the
// resolved prefs response, echoed on the reminder-hour PATCH.
updatedAt?: string;
}
export function MoodReminderCard({
@@ -132,10 +136,18 @@ export function MoodReminderCard({
clearTimerRef.current = null;
}
// v1.32.22 (R5b) — echo the base token so a concurrent notification-prefs
// write (the iOS client's `clientManaged` flag, another web card) 409s
// instead of silently reverting a sibling category through the deep-merge.
const res = await apiFetchRaw("/api/auth/me/notification-prefs", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mood: { reminderHour: next } }),
body: JSON.stringify(
withBaseToken(
{ mood: { reminderHour: next } },
readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()),
),
),
});
if (res.ok) {
@@ -146,6 +158,17 @@ export function MoodReminderCard({
});
setOptimisticHour(null);
scheduleClear();
} else if (res.status === 409) {
// The prefs advanced since they were loaded. Revert the optimistic hour
// and refetch (the active query reseeds a fresh token), then nudge — the
// change did not persist, so ask the user to re-apply it.
setOptimisticHour(null);
await queryClient.invalidateQueries({
queryKey: queryKeys.authNotificationPrefs(),
});
setMsg(t("common.conflictReloaded"));
setMsgType("error");
scheduleClear();
} else {
setOptimisticHour(null);
setMsg(t("notifications.moodReminder.saveError"));