feat(02-12.1): carry and verify the record context from the browser

- attach the asserted context to every same-origin request inside the
  three fetch entries, so no call site classifies itself
- discard a response whose echoed context contradicts the adopted one;
  serve one that carries no echo, which is what keeps the public poll
  and every actor surface working
- adopt from /api/auth/me and the switch response, and from nothing else
- send the epoch on every switch and retry once after reconciling
- hold and reconcile on the 409 instead of leaving the record
This commit is contained in:
Marc-André Bombeck committed 2026-08-06 19:10:02 +02:00
1 parent 3a99de312e
commit 31b4857d8b
15 files changed
+939 -25

No files matched your search

@@ -0,0 +1,80 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { onRecordFenceMismatch } from "@/lib/api/record-fence";
import { fetchMe } from "@/hooks/use-auth";
import { isRecordSessionChanged } from "@/hooks/use-account-switch";
import { queryKeys } from "@/lib/query-keys";
import { holdForRecordSessionReconcile } from "@/lib/query-keys/record-session-transition";
/**
* v1.37.0 — hold and reconcile when the server says this browser's record
* context has moved.
*
* Two signals mean the same thing and land in the same place:
*
* * a 409 `sharing.session.changed`, which the fence raises when the asserted
* context does not match the session row;
* * a 2xx whose ECHOED context contradicts the adopted one — a response that
* was in flight across a switch, or a service-worker replay of one cached
* before it.
*
* The response to both is the same and it is NOT the grant-loss response. This
* browser does not leave the record: it holds every tab, re-reads
* `/api/auth/me` network-only, adopts whatever context the server actually
* holds, and releases. `sharing.session.changed` is deliberately absent from
* the set `subscribeToGrantLoss` fires on — handing a reconcilable
* disagreement to a bridge that hard-navigates out of the record would turn a
* one-frame hold into an eviction.
*
* A sibling of `SharedRecordGrantLossBridge` rather than a branch inside it,
* for the reason that file gives for existing at all: the two answer different
* questions ("you have lost this record" versus "we disagree about which record
* this is"), and a single subscriber with a mode flag is how they start
* answering each other's.
*/
export function RecordSessionFenceBridge() {
const queryClient = useQueryClient();
useEffect(() => {
/**
* Held, then reconciled. `fetchMe` adopts the context and settles the
* transition itself, so nothing here decides what the browser is allowed
* to render next — the server's answer does.
*/
const reconcile = () => {
holdForRecordSessionReconcile();
void queryClient.cancelQueries();
queryClient.clear();
void queryClient
.fetchQuery({ queryKey: queryKeys.authMe(), queryFn: fetchMe })
.catch(() => {
// Offline, or the server is down. The hold stays, which is the
// correct place to be stuck: nothing renders under a context this
// browser cannot prove, and the next successful `/me` releases it.
});
};
// The in-flight / cached-response arm, raised by the transport wrapper.
const disposeMismatch = onRecordFenceMismatch(reconcile);
// The 409 arm. Watching the query cache rather than asking ~200 call sites
// to handle a status code, for the reason the grant-loss bridge gives:
// a per-call-site handler is a rule the next feature forgets.
const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
if (event.type !== "updated") return;
if (event.query.state.status !== "error") return;
if (!isRecordSessionChanged(event.query.state.error)) return;
reconcile();
});
return () => {
disposeMismatch();
unsubscribe();
};
}, [queryClient]);
return null;
}
@@ -171,6 +171,7 @@ describe("<ResearchModeAcknowledgmentDialog>", () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
json: async () => ({ data: { enabled: true } }),
// The apiFetch wrapper reads the envelope via `text()`.
text: async () => JSON.stringify({ data: { enabled: true } }),
@@ -399,6 +399,7 @@ describe("runTakeAllDue — per-medication loop with failure isolation", () => {
function okResponse(id: string) {
return {
ok: true,
headers: new Headers(),
json: vi.fn().mockResolvedValue({ data: { id } }),
text: vi.fn().mockResolvedValue(JSON.stringify({ data: { id } })),
};
@@ -454,7 +455,12 @@ describe("runTakeAllDue — per-medication loop with failure isolation", () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(okResponse("evt-a"))
.mockResolvedValueOnce({ ok: false, json: vi.fn(), text: vi.fn() });
.mockResolvedValueOnce({
ok: false,
headers: new Headers(),
json: vi.fn(),
text: vi.fn(),
});
vi.stubGlobal("fetch", fetchMock);
const queryClient = fakeQueryClient();
@@ -509,7 +515,12 @@ describe("runTakeAllDue — per-medication loop with failure isolation", () => {
it("reports total failure without invalidating (nothing changed server-side)", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({ ok: false, json: vi.fn(), text: vi.fn() }),
vi.fn().mockResolvedValue({
ok: false,
headers: new Headers(),
json: vi.fn(),
text: vi.fn(),
}),
);
const queryClient = fakeQueryClient();
@@ -109,6 +109,7 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => {
"fetch",
vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
json: vi.fn().mockResolvedValue({ data: { id: "evt-99" } }),
// The apiFetch wrapper reads the envelope via `text()`.
text: vi
@@ -154,6 +155,7 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => {
"fetch",
vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
json: vi.fn().mockResolvedValue({ data: { id: "evt-1" } }),
text: vi
.fn()
@@ -186,6 +188,7 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => {
"fetch",
vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
json: vi.fn().mockResolvedValue({ data: { id: "evt-1" } }),
text: vi
.fn()
@@ -212,6 +215,7 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => {
it("sends the displayed dose's scheduledFor on the POST so the server marks THAT slot (v1.12.3)", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
json: vi.fn().mockResolvedValue({ data: { id: "evt-am" } }),
text: vi
.fn()
@@ -243,6 +247,7 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => {
it("targets the morning vs evening slot purely from the supplied scheduledFor, not the wall-clock", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
json: vi.fn().mockResolvedValue({ data: { id: "evt" } }),
text: vi.fn().mockResolvedValue(JSON.stringify({ data: { id: "evt" } })),
});
@@ -285,6 +290,7 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => {
it("omits scheduledFor entirely on a PRN / unscheduled dose (null), preserving the server now-snap path", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
json: vi.fn().mockResolvedValue({ data: { id: "evt-prn" } }),
});
vi.stubGlobal("fetch", fetchMock);
@@ -311,6 +317,7 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => {
"fetch",
vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
json: vi.fn().mockResolvedValue({ data: {} }),
text: vi.fn().mockResolvedValue(JSON.stringify({ data: {} })),
}),
@@ -334,6 +341,7 @@ describe("runLogIntake — manual backdated intake from the Add choice", () => {
it("posts a backdated takenAt against the medication's intake route and returns true", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
json: vi.fn(),
text: vi.fn().mockResolvedValue(""),
});
@@ -367,6 +375,7 @@ describe("runLogIntake — manual backdated intake from the Add choice", () => {
it("threads scheduledFor (the chosen slot) so the write routes through the canonical slot upsert", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
json: vi.fn(),
text: vi.fn().mockResolvedValue(""),
});
@@ -394,6 +403,7 @@ describe("runLogIntake — manual backdated intake from the Add choice", () => {
it("omits takenAt on a skipped log and shows the skipped toast", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
json: vi.fn(),
text: vi.fn().mockResolvedValue(""),
});
@@ -445,6 +455,7 @@ describe("runUndoIntake — shared soft-delete", () => {
it("reverts via DELETE and confirms on success", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
text: vi.fn().mockResolvedValue(""),
});
vi.stubGlobal("fetch", fetchMock);
+2
View File
@@ -22,6 +22,7 @@ import { Toaster } from "@/components/ui/sonner";
import { VersionPoller } from "@/components/version-poller";
import { ServiceWorkerRegistrar } from "@/components/service-worker-registrar";
import { SharedRecordGrantLossBridge } from "@/components/layout/shared-record-grant-loss-bridge";
import { RecordSessionFenceBridge } from "@/components/layout/record-session-fence-bridge";
import { useAuth, type AccountAccessStatus } from "@/hooks/use-auth";
import { useRecordSessionTransition } from "@/hooks/use-record-session-transition";
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
@@ -394,6 +395,7 @@ export function Providers({
subscriber notices the stable errorCode and puts the browser back
in its own account, rather than asking every call site to. */}
<SharedRecordGrantLossBridge />
<RecordSessionFenceBridge />
{children}
<Toaster position="bottom-right" richColors />
<VersionPoller />