mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
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:
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);
|
||||
|
||||
@@ -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 />
|
||||
|
||||
Reference in new issue
Block a user