diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index c162169cd..57c3fd31e 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -131,6 +131,34 @@ without touching the file: - `HEALTHLOG_IMAGE_REF=:v1.16.5` — pin to a release tag (still mutable). - `HEALTHLOG_IMAGE_REF=@sha256:` — pin to the immutable digest. +#### Bumping a digest pin by hand + +A digest-pinned instance never follows a release on its own — an image +promote does not touch the pin, so moving it is part of shipping. The +mechanic, end to end: + +1. Resolve the new release's digest from the registry (the manifest-list + digest, which covers both architectures): + + ```sh + docker buildx imagetools inspect ghcr.io/mbombeck/healthlog:v1.37.20 \ + | grep -m1 Digest + ``` + +2. Set `HEALTHLOG_IMAGE_REF=@sha256:` where the compose + environment lives — the `.env` next to the compose file, or the + deployment platform's env vars for the app service. + +3. Deploy as usual. + +4. Verify `/api/version` on the target — and verify it **three times a + few seconds apart**, not once: during the container swap a single + probe can be answered by the outgoing container (or a cache) and read + as success while the old release keeps running. Three stable answers + reporting the shipped `version` + `buildSha` are the signal; a deploy + that finishes suspiciously fast (under ~30 s) is a warning that + nothing was actually pulled. + > **A pin is sticky, and a stale pin is silent.** Once set, every later deploy > ships that reference until someone changes it. The deploy still reports > success, the tag still builds, the image still publishes, and the running diff --git a/public/sw.js b/public/sw.js index 922bedb3c..6d7fa702a 100644 --- a/public/sw.js +++ b/public/sw.js @@ -79,6 +79,9 @@ const MAX_DATA_ENTRIES = 60; // and the version probe are eligible. const API_READ_ALLOWLIST = [ "/api/dashboard/snapshot", + // v1.37.20 — daily digest: the Today rail's offline read. Keep in + // lockstep with src/lib/pwa/sw-cache-policy.ts. + "/api/daily/digest", "/api/dashboard/widgets", "/api/measurements", "/api/medications", diff --git a/src/__tests__/data-wipe-completeness.test.ts b/src/__tests__/data-wipe-completeness.test.ts index af4b2d4b5..d2ceaf154 100644 --- a/src/__tests__/data-wipe-completeness.test.ts +++ b/src/__tests__/data-wipe-completeness.test.ts @@ -280,4 +280,36 @@ describe("data-wipe completeness", () => { OR: [{ recordUserId: "u1" }, { recipientUserId: "u1" }], }); }); + + it("freezes the set of user-linked tables that carry no foreign key", () => { + // v1.37.20 (A6-19) — NO_FK_BY_DESIGN, as an assertion instead of a pair + // of FK migrations. The two Telegram plumbing tables link to an account + // by a bare `userId` string on purpose: both hold a bounded, short-lived + // window of chat state (scheduled message deletions, prompt contexts) + // that a cron already reaps, and a cascade would buy nothing the wipe + // plan does not already do — rule 1 catches them because the column NAME + // matches the convention. That is the exact fragility this test freezes: + // a THIRD such table would also pass rule 1 silently, without anyone + // deciding it should. So the set is closed here; growing it is a + // decision made in this file, with a reason, or by adding the FK. + const NO_FK_BY_DESIGN = new Set([ + "TelegramScheduledDeletion", + "TelegramPromptContext", + ]); + const bareUserIdModels = [...models.entries()] + .filter( + ([name, shape]) => + name !== "User" && + shape.scalars.includes("userId") && + !shape.relations.some((rel) => rel.target === "User"), + ) + .map(([name]) => name) + .sort(); + expect(bareUserIdModels).toEqual([...NO_FK_BY_DESIGN].sort()); + // And both stay in the wipe plan — the assertion above is about schema + // shape, this one about the plan actually reaching them. + for (const model of NO_FK_BY_DESIGN) { + expect(WIPE_MODELS).toContain(model); + } + }); }); diff --git a/src/lib/fhir/build-bundle.ts b/src/lib/fhir/build-bundle.ts index 1f46d8a24..6947a0f8e 100644 --- a/src/lib/fhir/build-bundle.ts +++ b/src/lib/fhir/build-bundle.ts @@ -269,6 +269,14 @@ export function buildFhirDocumentBundle( // no route module lies. The PDF carries the visits; the Bundle does not, and // a reader who notices the gap should find this comment rather than a bug. // + // No vaccinations either, on the same terms (v1.37.20, A6-21). FHIR has + // `Immunization` and the model would map cleanly, but a resource type in + // the Bundle without its REST route module and capability entry is the + // exact half-shipped state the visits paragraph refuses; emitting it is a + // deliberate follow-up, not an oversight. The Impfpass PDF and the backup + // both carry the doses; the Bundle does not, and this comment is where a + // reader who notices should land. + // // held an episode (the aggregator gates `data.illnessEpisodes`). Each // episode emits a patient-reported Condition (generic SNOMED root, label on // `code.text`) plus a bounding Encounter that references it. diff --git a/src/lib/pwa/__tests__/sw-cache-policy.test.ts b/src/lib/pwa/__tests__/sw-cache-policy.test.ts index f54356710..bbd49082e 100644 --- a/src/lib/pwa/__tests__/sw-cache-policy.test.ts +++ b/src/lib/pwa/__tests__/sw-cache-policy.test.ts @@ -14,6 +14,9 @@ describe("isAllowlistedApiRead — offline read boundary", () => { it("caches the curated safe GET read endpoints", () => { expect(isAllowlistedApiRead("/api/dashboard/snapshot")).toBe(true); expect(isAllowlistedApiRead("/api/dashboard/widgets")).toBe(true); + // v1.37.20 — the Today rail's offline read. Watched red before the + // allowlist entry landed. + expect(isAllowlistedApiRead("/api/daily/digest")).toBe(true); expect(isAllowlistedApiRead("/api/measurements")).toBe(true); expect( isAllowlistedApiRead("/api/measurements/series-batch?types=WEIGHT"), diff --git a/src/lib/pwa/sw-cache-policy.ts b/src/lib/pwa/sw-cache-policy.ts index 1fd4022ee..f248535c3 100644 --- a/src/lib/pwa/sw-cache-policy.ts +++ b/src/lib/pwa/sw-cache-policy.ts @@ -27,6 +27,11 @@ export const API_READ_ALLOWLIST = [ "/api/dashboard/snapshot", "/api/dashboard/widgets", + // v1.37.20 — the daily digest read: the Today rail is the first thing an + // offline reopen renders, and the digest is an idempotent GET with no + // secret in the body. The deny guard and the body-level refusal still + // apply on top, like every entry here. + "/api/daily/digest", "/api/measurements", "/api/medications", "/api/version",