From b4577f5c31e6dd63f1fc59e1da98669066bf4c5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 10:52:48 +0200 Subject: [PATCH 1/6] test: give every scanning guard a floor on what it found A guard that compares a sweep against an allowlist, or against "none of these may exist", passes when the sweep comes back empty. walkSourceFiles already refuses a short walk; this adds the same refusal for what a guard does with the walk. - requireFloor(label, found, floor) throws below the floor and refuses a floor of zero. - scanSourceMatches(root, pattern, { fileFloor, matchFloor }) walks, matches and floors both the files read and the hits found. Floors added, pinned at the current counts: - coach_usage owner columns: the clauses it actually examined (6). - measurement reference index: the foreign keys it extracted (2). - job outcome fact inventory: job files (130, floor 120) and fact keys (399, floor 350). - tone harness: assessment prompt modules (11). - coach locale default: turn pipeline modules (15, floor 12). - locale integrity: message bundles (7). - settings card header coverage and settings anatomy: the walked cards. - coach metric scope inventory: insight route sources. - side-effect drift guard: both enums populated, since two empty keysets are equal. The pill-mapper guard walked with globSync, which skips dot-prefixed directories; it now walks through walkSourceFiles like the rest. --- .../coach-usage-owner-columns-guard.test.ts | 4 + src/__tests__/helpers/source-files.ts | 93 ++++++++++++++++++- .../measurement-reference-index-guard.test.ts | 1 + .../coach-metric-scope-inventory.test.ts | 7 +- .../__tests__/one-piece-anatomy.test.tsx | 9 +- .../settings-card-header-coverage.test.ts | 5 +- .../__tests__/pill-mapper.test.tsx | 15 ++- .../__tests__/i18n-locale-integrity.test.ts | 6 +- .../coach-locale-no-german-default.test.ts | 14 ++- .../eval/__tests__/tone-harness.test.ts | 21 +++-- .../job-outcome-source-inventory.test.ts | 21 +++-- .../__tests__/drift-guard.test.ts | 7 ++ 12 files changed, 166 insertions(+), 37 deletions(-) diff --git a/src/__tests__/coach-usage-owner-columns-guard.test.ts b/src/__tests__/coach-usage-owner-columns-guard.test.ts index 7b12a1a95..4a405d812 100644 --- a/src/__tests__/coach-usage-owner-columns-guard.test.ts +++ b/src/__tests__/coach-usage-owner-columns-guard.test.ts @@ -98,12 +98,14 @@ describe("coach_usage — the two counters move together", () => { it("names operator_tokens in every clause that names total_tokens", () => { const offences: string[] = []; + let examined = 0; for (const file of files) { const source = readFileSync(file, "utf8"); if (!source.includes("coach_usage")) continue; for (const literal of sqlLiterals(source)) { for (const clause of columnClauses(literal)) { if (!clause.text.includes("total_tokens")) continue; + examined += 1; if (clause.text.includes("operator_tokens")) continue; offences.push( `${relative(REPO_ROOT, file)} — ${clause.label}: ${clause.text @@ -118,6 +120,8 @@ describe("coach_usage — the two counters move together", () => { offences, `Every raw coach_usage clause that touches total_tokens must touch operator_tokens too:\n${offences.join("\n")}`, ).toEqual([]); + // No clauses examined would agree with the rule above on nothing. + expect(examined).toBeGreaterThanOrEqual(6); }); it("keeps the raw-SQL writer set to the files that reason about the ledger", () => { diff --git a/src/__tests__/helpers/source-files.ts b/src/__tests__/helpers/source-files.ts index 9a2a3cff1..15a5da508 100644 --- a/src/__tests__/helpers/source-files.ts +++ b/src/__tests__/helpers/source-files.ts @@ -50,8 +50,8 @@ * worktrees, each a full second copy of the tree that would double every * match. Every caller roots at `src/`, `e2e/` or a subtree of one. */ -import { readdirSync } from "node:fs"; -import { sep } from "node:path"; +import { readdirSync, readFileSync } from "node:fs"; +import { join, sep } from "node:path"; export function walkSourceFiles( root: string, @@ -140,3 +140,92 @@ export function stripComments(source: string): string { return lines.join("\n"); } + +/** + * Asserts that a sweep found at least `floor` things, and returns them. + * + * `walkSourceFiles` puts a floor under the walk. This puts one under what a + * guard does with it: the narrowed file set, the call sites a matcher picked + * out, the routes an inventory extracted. A matcher that drifts out of step + * with the code — a renamed helper, a call split across two lines, a regex + * that stopped compiling the way it was meant to — returns an empty list, and + * an empty list agrees with every allowlist and every "none of these may + * exist" rule. That is how the Bearer-scope guard stayed green for weeks while + * matching nothing. + * + * Pin `floor` at the real count when the set is a closed inventory, and below + * it with headroom only when the count legitimately moves. A floor of zero is + * refused: it is the bug this exists to prevent, spelled as a parameter. + */ +export function requireFloor( + label: string, + found: readonly T[], + floor: number, +): readonly T[] { + if (!Number.isInteger(floor) || floor < 1) { + throw new Error( + `requireFloor(${label}): floor must be a positive integer, got ${floor}.`, + ); + } + if (found.length < floor) { + throw new Error( + `${label}: found ${found.length}, below the stated floor of ${floor}. ` + + `A matcher that finds this little has drifted from the code it ` + + `reads; fix the matcher before lowering the floor.`, + ); + } + return found; +} + +export interface SourceMatch { + /** Path relative to the scanned root, posix-separated. */ + file: string; + /** 1-based line of the match start. */ + line: number; + /** The matched text. */ + text: string; +} + +/** + * Walk `root`, run `pattern` over every kept file, and return each match — + * with a floor on the files read AND a floor on the matches found. + * + * `pattern` must carry the `g` flag; it is the only way to report more than + * one hit per file, and a non-global regex silently finding one hit per file + * is the kind of quiet narrowing this helper is for. + */ +export function scanSourceMatches( + root: string, + pattern: RegExp, + options: { + fileFloor: number; + matchFloor: number; + extensions?: readonly string[]; + include?: (rel: string) => boolean; + stripComments?: boolean; + }, +): SourceMatch[] { + if (!pattern.global) { + throw new Error(`scanSourceMatches: pattern ${pattern} needs the g flag.`); + } + const files = walkSourceFiles(root, { + floor: options.fileFloor, + extensions: options.extensions, + }).filter(options.include ?? (() => true)); + requireFloor(`scanSourceMatches(${root}) files`, files, options.fileFloor); + + const matches: SourceMatch[] = []; + for (const file of files) { + const raw = readFileSync(join(root, file), "utf8"); + const source = options.stripComments ? stripComments(raw) : raw; + for (const m of source.matchAll(pattern)) { + const line = source.slice(0, m.index).split("\n").length; + matches.push({ file, line, text: m[0] }); + } + } + return requireFloor( + `scanSourceMatches(${root}, ${pattern})`, + matches, + options.matchFloor, + ) as SourceMatch[]; +} diff --git a/src/__tests__/measurement-reference-index-guard.test.ts b/src/__tests__/measurement-reference-index-guard.test.ts index ebf7c28db..4cb727aa6 100644 --- a/src/__tests__/measurement-reference-index-guard.test.ts +++ b/src/__tests__/measurement-reference-index-guard.test.ts @@ -64,6 +64,7 @@ describe("columns that point into measurements are indexed", () => { const refs = measurementReferences(); it("finds the references (a matcher that finds none proves nothing)", () => { + expect(refs.length).toBeGreaterThanOrEqual(2); expect(refs).toEqual( expect.arrayContaining([ "ecg_recordings.measurement_id", diff --git a/src/components/insights/__tests__/coach-metric-scope-inventory.test.ts b/src/components/insights/__tests__/coach-metric-scope-inventory.test.ts index 34f8afb5a..573d475c2 100644 --- a/src/components/insights/__tests__/coach-metric-scope-inventory.test.ts +++ b/src/components/insights/__tests__/coach-metric-scope-inventory.test.ts @@ -4,6 +4,7 @@ import { join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; import type { CoachScopeSource } from "@/lib/ai/coach/types"; +import { requireFloor } from "@/__tests__/helpers/source-files"; import { scopeSourceFromMetricKey, scopeSourceMetricLabelKey, @@ -38,8 +39,10 @@ function sourceFilesBelow(directory: string): string[] { } function genericMetricCardInventory(): Set { - const insightRouteSources = sourceFilesBelow( - join(PROJECT_ROOT, "src/app/insights"), + const insightRouteSources = requireFloor( + "insight route sources", + sourceFilesBelow(join(PROJECT_ROOT, "src/app/insights")), + 60, ).map((path) => readFileSync(path, "utf8")); const recoverySource = readFileSync( join(PROJECT_ROOT, "src/components/insights/recovery/recovery-section.tsx"), diff --git a/src/components/settings/__tests__/one-piece-anatomy.test.tsx b/src/components/settings/__tests__/one-piece-anatomy.test.tsx index d801cf1e7..48104c044 100644 --- a/src/components/settings/__tests__/one-piece-anatomy.test.tsx +++ b/src/components/settings/__tests__/one-piece-anatomy.test.tsx @@ -9,6 +9,7 @@ import { PageHeader } from "@/components/ui/page-header"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { requireFloor } from "@/__tests__/helpers/source-files"; /** * Settings + Admin read as one surface. These pin the parts of that claim @@ -179,8 +180,12 @@ describe("the Settings and Admin shells route their heading through PageHeader", describe("the abolished body gutter stays abolished", () => { it("has no `pl-7` left under Settings or Admin", () => { const offenders: string[] = []; - for (const dir of ["settings", "admin"]) { - for (const file of walk(join(ROOT, "src", "components", dir))) { + for (const [dir, floor] of [ + ["settings", 130], + ["admin", 30], + ] as const) { + const files = walk(join(ROOT, "src", "components", dir)); + for (const file of requireFloor(`${dir} components`, files, floor)) { const source = readFileSync(file, "utf8"); for (const [i, line] of source.split("\n").entries()) { // Comments may name the class they retired; class strings may not. diff --git a/src/components/settings/__tests__/settings-card-header-coverage.test.ts b/src/components/settings/__tests__/settings-card-header-coverage.test.ts index 6e3d1bfa6..1a9b86748 100644 --- a/src/components/settings/__tests__/settings-card-header-coverage.test.ts +++ b/src/components/settings/__tests__/settings-card-header-coverage.test.ts @@ -23,6 +23,8 @@ import { describe, it, expect } from "vitest"; import { readFileSync, readdirSync, statSync } from "node:fs"; import { join, relative } from "node:path"; +import { requireFloor } from "@/__tests__/helpers/source-files"; + const ROOT = join(__dirname, "../../../.."); const SETTINGS_DIR = join(ROOT, "src/components/settings"); @@ -59,7 +61,8 @@ describe("Settings card headers route through SettingsCardHeader", () => { it("has no hand-rolled

outside the primitive + allowlist", () => { const offenders: string[] = []; - for (const file of walk(SETTINGS_DIR)) { + const files = requireFloor("settings cards", walk(SETTINGS_DIR), 80); + for (const file of files) { const base = file.slice(file.lastIndexOf("/") + 1); if (base === PRIMITIVE) continue; if (ALLOWLIST.has(base)) continue; diff --git a/src/components/settings/integrations/__tests__/pill-mapper.test.tsx b/src/components/settings/integrations/__tests__/pill-mapper.test.tsx index 28b3e013c..f67be81d0 100644 --- a/src/components/settings/integrations/__tests__/pill-mapper.test.tsx +++ b/src/components/settings/integrations/__tests__/pill-mapper.test.tsx @@ -9,8 +9,9 @@ */ import { describe, expect, it } from "vitest"; import { readFileSync } from "node:fs"; -import { join, sep } from "node:path"; -import { globSync } from "node:fs"; +import { join } from "node:path"; + +import { walkSourceFiles } from "@/__tests__/helpers/source-files"; import type { SyncVerdict } from "@/lib/integrations/sync-verdict"; import { @@ -113,14 +114,10 @@ describe("structural guard — no second pill dialect", () => { const SRC = join(process.cwd(), "src"); function sourceFiles(): string[] { - return globSync("**/*.{ts,tsx}", { cwd: SRC }) - .filter( - (p) => !p.startsWith(`generated${sep}`) && !p.startsWith("generated/"), - ) + return walkSourceFiles(SRC, { floor: 3000 }) + .filter((p) => !p.startsWith("generated/")) .filter((p) => !p.includes("__tests__")) - .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")) - .map((p) => p.split(sep).join("/")) - .sort(); + .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")); } /** diff --git a/src/lib/__tests__/i18n-locale-integrity.test.ts b/src/lib/__tests__/i18n-locale-integrity.test.ts index 0fb001b28..267a21b32 100644 --- a/src/lib/__tests__/i18n-locale-integrity.test.ts +++ b/src/lib/__tests__/i18n-locale-integrity.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest"; import { readFileSync, readdirSync } from "node:fs"; import { join } from "node:path"; +import { requireFloor } from "@/__tests__/helpers/source-files"; + const ROOT = join(__dirname, "../../.."); const MESSAGES_DIR = join(ROOT, "messages"); const EN_PATH = join(MESSAGES_DIR, "en.json"); @@ -20,7 +22,9 @@ function discoverLocales(): Array<{ locale: string; path: string }> { })); } -const ALL_LOCALES = discoverLocales(); +// Every parity check below iterates this list, so an empty discovery would +// run none of them and pass. Seven bundles ship today. +const ALL_LOCALES = requireFloor("message bundles", discoverLocales(), 7); const NON_EN_LOCALES = ALL_LOCALES.filter((l) => l.locale !== "en"); // JSON.parse silently keeps the last value when an object has duplicate keys, diff --git a/src/lib/ai/coach/__tests__/coach-locale-no-german-default.test.ts b/src/lib/ai/coach/__tests__/coach-locale-no-german-default.test.ts index c295304c1..68ab22cca 100644 --- a/src/lib/ai/coach/__tests__/coach-locale-no-german-default.test.ts +++ b/src/lib/ai/coach/__tests__/coach-locale-no-german-default.test.ts @@ -16,6 +16,8 @@ import { describe, it, expect } from "vitest"; import { readdirSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; +import { requireFloor } from "@/__tests__/helpers/source-files"; + const ROOT = resolve(__dirname, "../../../.."); /** @@ -23,10 +25,14 @@ const ROOT = resolve(__dirname, "../../../.."); * so every module there is guarded — read from the directory, so a step added * later is covered without editing this list. */ -const TURN_PIPELINE_FILES = readdirSync(resolve(ROOT, "lib/ai/coach/turn")) - .filter((name) => name.endsWith(".ts")) - .sort() - .map((name) => `lib/ai/coach/turn/${name}`); +const TURN_PIPELINE_FILES = requireFloor( + "coach turn pipeline modules", + readdirSync(resolve(ROOT, "lib/ai/coach/turn")) + .filter((name) => name.endsWith(".ts")) + .sort() + .map((name) => `lib/ai/coach/turn/${name}`), + 12, +); const GUARDED_FILES = [ "app/api/insights/chat/route.ts", diff --git a/src/lib/insights/eval/__tests__/tone-harness.test.ts b/src/lib/insights/eval/__tests__/tone-harness.test.ts index a6e9b2e99..c5ab6f687 100644 --- a/src/lib/insights/eval/__tests__/tone-harness.test.ts +++ b/src/lib/insights/eval/__tests__/tone-harness.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; +import { requireFloor } from "@/__tests__/helpers/source-files"; import { locales, type Locale } from "@/lib/i18n/config"; import { openingShape } from "@/lib/ai/prompts/shared-contracts"; import { @@ -110,14 +111,18 @@ describe("tone harness — prompt surfaces", () => { "native-prompts.ts", "status-batch.ts", ]); - const modules = readdirSync(dir).filter( - (f) => - f.endsWith(".ts") && - !f.endsWith(".d.ts") && - !NON_ASSESSMENT.has(f) && - /export function get\w*SystemPrompt/.test( - readFileSync(join(dir, f), "utf8"), - ), + const modules = requireFloor( + "assessment prompt modules", + readdirSync(dir).filter( + (f) => + f.endsWith(".ts") && + !f.endsWith(".d.ts") && + !NON_ASSESSMENT.has(f) && + /export function get\w*SystemPrompt/.test( + readFileSync(join(dir, f), "utf8"), + ), + ), + 11, ); const registered = new Set( ASSESSMENT_SURFACES.map((s) => diff --git a/src/lib/jobs/__tests__/job-outcome-source-inventory.test.ts b/src/lib/jobs/__tests__/job-outcome-source-inventory.test.ts index 02197386b..dc399f983 100644 --- a/src/lib/jobs/__tests__/job-outcome-source-inventory.test.ts +++ b/src/lib/jobs/__tests__/job-outcome-source-inventory.test.ts @@ -13,6 +13,7 @@ import { join } from "node:path"; import ts from "typescript"; import { describe, expect, it } from "vitest"; +import { requireFloor } from "@/__tests__/helpers/source-files"; import { JOB_FACT_ALLOWLIST } from "@/lib/jobs/job-outcome"; const JOBS_ROOT = join(process.cwd(), "src/lib/jobs"); @@ -109,14 +110,18 @@ function successFactKeys(file: string): string[] { describe("durable production job-outcome fact inventory", () => { it("allows every explicit production success-fact key", () => { - const unknown = productionJobFiles() - .flatMap((file) => - successFactKeys(file).map((key) => ({ - file: file.replace(`${process.cwd()}/`, ""), - key, - })), - ) - .filter(({ key }) => !JOB_FACT_ALLOWLIST.has(key)); + const facts = requireFloor( + "jobDone fact keys", + requireFloor("production job files", productionJobFiles(), 120).flatMap( + (file) => + successFactKeys(file).map((key) => ({ + file: file.replace(`${process.cwd()}/`, ""), + key, + })), + ), + 350, + ); + const unknown = facts.filter(({ key }) => !JOB_FACT_ALLOWLIST.has(key)); expect(unknown).toEqual([]); }); diff --git a/src/lib/medications/side-effects/__tests__/drift-guard.test.ts b/src/lib/medications/side-effects/__tests__/drift-guard.test.ts index 56b363d37..0037bda66 100644 --- a/src/lib/medications/side-effects/__tests__/drift-guard.test.ts +++ b/src/lib/medications/side-effects/__tests__/drift-guard.test.ts @@ -44,6 +44,13 @@ function sortedKeys(keys: readonly T[]): T[] { } describe("side-effect taxonomy drift guard", () => { + it("compares populated keysets, not two empty ones", () => { + // Every check below is an equality, and two empty sets are equal. A + // mocked or mis-resolved enum would pass all of them on nothing. + expect(Object.values(MedicationSideEffectCategory).length).toBe(5); + expect(Object.values(MedicationSideEffectEntry).length).toBe(21); + }); + describe("category keysets", () => { it("validator categories equal Prisma enum categories", () => { const prisma = Object.values(MedicationSideEffectCategory); From f3bf65fb4e80e08d5450058f4ca55c4c171922dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 10:52:48 +0200 Subject: [PATCH 2/6] test: fail any structural guard that cannot fail on an empty sweep guard-floor-guard reads every *-guard(s).test file under src/ and every test that walks a directory, parses it with the TypeScript compiler, and fails unless it asserts a floor on what it found: requireFloor, scanSourceMatches, toBeGreaterThan, an exact non-zero count, or an exact non-empty set. A floor on the walk alone does not count; the Bearer-scope guard had one and still matched nothing for weeks. Files that are not scanners (unit tests of runtime functions named "guard", fixed file lists with a positive match per file, temp-directory listings) are named with a reason. An exemption that goes stale fails the run. The reader's own shapes are pinned by probe sources, including the ones it must not count: comments, strings, .not, zero floors, it.skip. It answers per file, not per matcher, and says so. --- src/__tests__/guard-floor-guard.test.ts | 229 ++++++++++++++++++++++++ src/__tests__/helpers/guard-floor.ts | 214 ++++++++++++++++++++++ 2 files changed, 443 insertions(+) create mode 100644 src/__tests__/guard-floor-guard.test.ts create mode 100644 src/__tests__/helpers/guard-floor.ts diff --git a/src/__tests__/guard-floor-guard.test.ts b/src/__tests__/guard-floor-guard.test.ts new file mode 100644 index 000000000..bce4fae04 --- /dev/null +++ b/src/__tests__/guard-floor-guard.test.ts @@ -0,0 +1,229 @@ +/** + * Every structural guard asserts that it found something. + * + * A guard that sweeps the tree and compares the result against an allowlist, + * or against "none of these may exist", passes on an empty sweep. That is not + * hypothetical here: the Bearer-scope guard stayed green for weeks because its + * matcher demanded `apiToken.findUnique(` on one line and the one file that + * mattered wrote it across two. The sweep matched nothing, nothing disagreed + * with the allowlist, and the guard reported a clean tree. + * + * So this reads every guard and fails unless it states a floor — a count it + * must reach — or is named below with the reason it does not need one. + * + * ## Scope + * + * Every test file under `src/` named `*-guard.test.ts(x)` or + * `*-guards.test.ts(x)`, plus every test file that walks a directory + * (`readdirSync`, `globSync`, `walkSourceFiles`), whatever it is called: the + * inventories and coverage checks are the same machine under another name. + * + * ## What counts as a floor + * + * The shapes `analyseGuardSource` recognises: `requireFloor`, + * `scanSourceMatches`, `toBeGreaterThan(n ≥ 0)`, `toBeGreaterThanOrEqual(n ≥ + * 1)`, `.not.toHaveLength(0)`, `toHaveLength(n ≥ 1)`, an exact + * `expect(x.length).toBe(n ≥ 1)`, and an exact `toEqual([...])` against a + * non-empty literal set. A floor on the walk alone (`walkSourceFiles`'s own + * `floor`) does not count: it proves the tree was read, not that the matcher + * found anything in it, and the Bearer guard had one. + * + * ## Its limit + * + * It answers per file. A guard with three matchers and a floor under one of + * them passes. Reviewers still own the question of which set the floor sits + * under. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { analyseGuardSource } from "./helpers/guard-floor"; +import { + requireFloor, + scanSourceMatches, + walkSourceFiles, +} from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); + +/** + * In scope, without a floor, and why that is right. Paths relative to `src/`. + * An entry that gains a floor, or whose file disappears, fails the run, so the + * list cannot outlive its reasons. + */ +const NO_FLOOR_NEEDED: Readonly> = { + // Fixed file lists. Each file is read by name, so a missing one throws, + // and each must positively match the shape it is supposed to keep. + "__tests__/backup-readers-stream-guard.test.ts": + "named readers, each must positively reference the streaming reader", + "__tests__/proxy-relative-redirect-guard.test.ts": + "named routes, each must positively call relativeRedirect(", + "__tests__/rotation-walk-keyset-guard.test.ts": + "named walks, each must positively match the keyset cursor", + "components/charts/__tests__/touch-action-guard.test.ts": + "named chart wrappers, each must positively declare touch-pan-y", + "__tests__/moodlog-removal-guard.test.ts": + "absence checks over named paths, columns and keys; no matcher to drift", + + // Unit tests of runtime code whose name ends in "guard". They read no + // source and sweep nothing. + "app/__tests__/error-chunk-reload-guard.test.ts": + "unit test of the chunk-reload guard function", + "app/api/insights/chat/__tests__/route-fence-guard.test.ts": + "route behaviour test of the chat fence", + "app/api/insights/chat/__tests__/route-guard.test.ts": + "route behaviour test of the outbound screen", + "lib/ai/__tests__/consent-guard.test.ts": "unit test of the consent gate", + "lib/ai/coach/__tests__/outbound-guard.test.ts": + "unit test of the outbound screen", + "lib/analytics/score/__tests__/config-delta-guard.test.ts": + "unit test of the score delta boundary", + "lib/medications/__tests__/route-guards.test.ts": + "unit test of the ownership helper", + + // List a temporary directory the test itself filled. + "lib/import/__tests__/apple-health-staging.test.ts": + "lists its own temp staging directory", + "lib/multipart/__tests__/stream-to-disk.test.ts": + "lists its own temp directory", +}; + +const NAMED_GUARD = /-guards?\.test\.tsx?$/; + +interface Scoped { + rel: string; + named: boolean; + floors: string[]; +} + +function guardsInScope(): Scoped[] { + const scoped: Scoped[] = []; + const tests = walkSourceFiles(SRC, { floor: 3000 }) + .filter((rel) => !rel.startsWith("generated/")) + .filter((rel) => /\.test\.tsx?$/.test(rel)); + for (const rel of requireFloor("test files under src/", tests, 1500)) { + const named = NAMED_GUARD.test(rel); + const report = analyseGuardSource( + rel, + readFileSync(join(SRC, rel), "utf8"), + ); + if (!named && !report.walksDirectories) continue; + scoped.push({ rel, named, floors: report.floors }); + } + return scoped; +} + +/** Floors that say something about matches, not just about the walk. */ +function matchFloors(floors: readonly string[]): string[] { + return floors.filter((f) => f !== "walkSourceFiles"); +} + +describe("every structural guard states a floor", () => { + const scoped = guardsInScope(); + + it("finds the guards (this check has a floor too)", () => { + expect(scoped.filter((g) => g.named).length).toBeGreaterThanOrEqual(100); + expect(scoped.filter((g) => !g.named).length).toBeGreaterThanOrEqual(40); + }); + + it("each one asserts a floor on what it found, or says why not", () => { + const missing = scoped + .filter((g) => matchFloors(g.floors).length === 0) + .filter((g) => !(g.rel in NO_FLOOR_NEEDED)) + .map((g) => g.rel); + expect( + missing, + "These guards can pass on a sweep that found nothing. Add a floor " + + "(requireFloor, scanSourceMatches, or expect(count).toBeGreaterThan…) " + + "pinned at the real count, or name the file in NO_FLOOR_NEEDED with " + + "the reason it is not a scanner.", + ).toEqual([]); + }); + + it("names no file in the exemption list that no longer needs it", () => { + const byRel = new Map(scoped.map((g) => [g.rel, g])); + const stale = Object.keys(NO_FLOOR_NEEDED).filter((rel) => { + const g = byRel.get(rel); + return !g || matchFloors(g.floors).length > 0; + }); + expect( + stale, + "Exempt files that are gone, out of scope, or now carry a floor", + ).toEqual([]); + }); +}); + +describe("the floor reader counts what it should and nothing else", () => { + const floorsOf = (source: string) => + matchFloors(analyseGuardSource("probe.test.ts", source).floors); + + it.each([ + ["requireFloor", `requireFloor("x", found, 3);`], + ["toBeGreaterThan(0)", `expect(found.length).toBeGreaterThan(0);`], + ["toBeGreaterThanOrEqual(n)", `expect(n).toBeGreaterThanOrEqual(12);`], + ["a named floor", `expect(n).toBeGreaterThanOrEqual(FLOOR);`], + ["not.toHaveLength(0)", `expect(found).not.toHaveLength(0);`], + ["exact length", `expect(found.length).toBe(7);`], + ["exact non-empty set", `expect(found).toEqual(["lib/a.ts"]);`], + [ + "scanSourceMatches", + `scanSourceMatches(root, /x/g, { fileFloor: 10, matchFloor: 2 });`, + ], + ])("counts %s", (_label, source) => { + expect(floorsOf(source)).toHaveLength(1); + }); + + it.each([ + ["a floor in a comment", `// expect(n).toBeGreaterThan(0);`], + ["a floor in a string", `const s = "expect(n).toBeGreaterThan(0)";`], + ["a negated floor", `expect(n).not.toBeGreaterThan(0);`], + ["GreaterThanOrEqual(0)", `expect(n).toBeGreaterThanOrEqual(0);`], + ["a zero requireFloor", `requireFloor("x", found, 0);`], + ["a zero matchFloor", `scanSourceMatches(r, /x/g, { matchFloor: 0 });`], + ["an empty-set equality", `expect(offenders).toEqual([]);`], + ["a spread-only set", `expect(found).toEqual([...EXPECTED]);`], + ["toHaveLength(0)", `expect(found).toHaveLength(0);`], + ["a walk floor alone", `walkSourceFiles(SRC, { floor: 3000 });`], + [ + "a floor inside it.skip", + `it.skip("x", () => { expect(n).toBeGreaterThan(0); });`, + ], + ])("does not count %s", (_label, source) => { + expect(floorsOf(source)).toEqual([]); + }); + + it("still notices the walk, so a walker stays in scope", () => { + const report = analyseGuardSource( + "probe.test.ts", + `const files = readdirSync(dir);`, + ); + expect(report.walksDirectories).toBe(true); + }); +}); + +describe("the floor helpers refuse an empty answer", () => { + it("requireFloor throws below the floor and refuses a zero floor", () => { + expect(() => requireFloor("probe", [], 1)).toThrow(/below the stated/); + expect(() => requireFloor("probe", [1, 2], 0)).toThrow(/positive/); + expect(requireFloor("probe", [1, 2], 2)).toEqual([1, 2]); + }); + + it("scanSourceMatches floors files and matches separately", () => { + const helpers = join(SRC, "__tests__", "helpers"); + const hits = scanSourceMatches(helpers, /export function \w+/g, { + fileFloor: 5, + matchFloor: 5, + }); + expect(hits.some((h) => h.file === "source-files.ts")).toBe(true); + expect(() => + scanSourceMatches(helpers, /no such text anywhere\u0000/g, { + fileFloor: 5, + matchFloor: 1, + }), + ).toThrow(/below the stated floor/); + expect(() => + scanSourceMatches(helpers, /export/, { fileFloor: 5, matchFloor: 1 }), + ).toThrow(/g flag/); + }); +}); diff --git a/src/__tests__/helpers/guard-floor.ts b/src/__tests__/helpers/guard-floor.ts new file mode 100644 index 000000000..5fd59739e --- /dev/null +++ b/src/__tests__/helpers/guard-floor.ts @@ -0,0 +1,214 @@ +/** + * Reads a structural guard's source and answers one question: does it assert + * that it found something? + * + * A guard that sweeps the tree and compares what it found against an + * allowlist, or against "none of these may exist", passes on an empty sweep. + * So every scanner has to say, somewhere, that its sweep was not empty. The + * shapes that count as saying it are listed on `FLOOR_SHAPES` below; anything + * else is not a floor. + * + * This parses the file with the TypeScript compiler rather than grepping it, + * so a floor that only appears in a comment, inside a string, under `.not`, or + * inside an `it.skip` does not count. + * + * Its honest limit: it answers per file, not per matcher. A guard with three + * matchers and a floor on one of them passes here. The floor proves the file + * read something; it does not prove every rule in the file did. + */ +import ts from "typescript"; + +export type FloorShape = + | "walkSourceFiles" + | "scanSourceMatches" + | "requireFloor" + | "toBeGreaterThan" + | "toBeGreaterThanOrEqual" + | "not.toHaveLength(0)" + | "toHaveLength(n>0)" + | "length.toBe(n>0)" + | "toEqual([non-empty])"; + +/** Calls that make a file a directory scanner, and so put it in scope. */ +const WALKERS = new Set(["readdirSync", "globSync", "walkSourceFiles"]); + +function numericValue(node: ts.Expression | undefined): number | null { + if (!node) return null; + if (ts.isNumericLiteral(node)) return Number(node.text); + if ( + ts.isPrefixUnaryExpression(node) && + node.operator === ts.SyntaxKind.MinusToken && + ts.isNumericLiteral(node.operand) + ) { + return -Number(node.operand.text); + } + return null; +} + +function calleeName(call: ts.CallExpression): string | null { + const callee = call.expression; + if (ts.isIdentifier(callee)) return callee.text; + if (ts.isPropertyAccessExpression(callee)) return callee.name.text; + return null; +} + +/** `x.not.toBeGreaterThan(…)` — the matcher sits under a `.not`. */ +function isNegated(call: ts.CallExpression): boolean { + const callee = call.expression; + return ( + ts.isPropertyAccessExpression(callee) && + ts.isPropertyAccessExpression(callee.expression) && + callee.expression.name.text === "not" + ); +} + +/** `expect(.length, …).toBe(…)` — the subject is a length. */ +function expectSubjectIsLength(call: ts.CallExpression): boolean { + const callee = call.expression; + if (!ts.isPropertyAccessExpression(callee)) return false; + const expectCall = callee.expression; + if ( + !ts.isCallExpression(expectCall) || + !ts.isIdentifier(expectCall.expression) || + expectCall.expression.text !== "expect" + ) { + return false; + } + const subject = expectCall.arguments[0]; + return ( + subject !== undefined && + ts.isPropertyAccessExpression(subject) && + subject.name.text === "length" + ); +} + +/** Inside `it.skip(…)`, `describe.todo(…)`, `test.skip(…)` and friends. */ +function isInSkippedBlock(node: ts.Node): boolean { + for (let cur = node.parent; cur; cur = cur.parent) { + if ( + ts.isCallExpression(cur) && + ts.isPropertyAccessExpression(cur.expression) && + ["skip", "todo"].includes(cur.expression.name.text) && + ts.isIdentifier(cur.expression.expression) && + ["it", "test", "describe"].includes(cur.expression.expression.text) + ) { + return true; + } + } + return false; +} + +/** The value of property `key` on an object-literal argument, if any. */ +function objectProperty( + arg: ts.Expression | undefined, + key: string, +): ts.Expression | "shorthand" | undefined { + if (!arg || !ts.isObjectLiteralExpression(arg)) return undefined; + for (const prop of arg.properties) { + if (ts.isShorthandPropertyAssignment(prop) && prop.name.text === key) { + return "shorthand"; + } + if ( + ts.isPropertyAssignment(prop) && + (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) && + prop.name.text === key + ) { + return prop.initializer; + } + } + return undefined; +} + +/** A floor value counts unless it is a literal below `min`. */ +function floorValueCounts( + value: ts.Expression | "shorthand" | undefined, + min: number, +): boolean { + if (value === undefined) return false; + if (value === "shorthand") return true; + const n = numericValue(value); + return n === null || n >= min; +} + +function floorShapeOf(call: ts.CallExpression): FloorShape | null { + const name = calleeName(call); + const [first, second, third] = call.arguments; + switch (name) { + case "walkSourceFiles": + return floorValueCounts(objectProperty(second, "floor"), 1) + ? "walkSourceFiles" + : null; + case "scanSourceMatches": + return floorValueCounts(objectProperty(third, "matchFloor"), 1) + ? "scanSourceMatches" + : null; + case "requireFloor": + return floorValueCounts(third, 1) ? "requireFloor" : null; + case "toBeGreaterThan": + if (isNegated(call)) return null; + return floorValueCounts(first, 0) ? "toBeGreaterThan" : null; + case "toBeGreaterThanOrEqual": + if (isNegated(call)) return null; + return floorValueCounts(first, 1) ? "toBeGreaterThanOrEqual" : null; + case "toHaveLength": { + const n = numericValue(first); + if (isNegated(call)) return n === 0 ? "not.toHaveLength(0)" : null; + return n !== null && n >= 1 ? "toHaveLength(n>0)" : null; + } + case "toBe": + case "toEqual": + case "toStrictEqual": { + if (isNegated(call)) return null; + // `expect(found).toEqual(["lib/one.ts"])` — the exact, non-empty set. + if ( + name !== "toBe" && + first !== undefined && + ts.isArrayLiteralExpression(first) && + first.elements.some((el) => !ts.isSpreadElement(el)) + ) { + return "toEqual([non-empty])"; + } + // `expect(found.length).toBe(12)` — an exact non-zero count. + const n = numericValue(first); + if (n === null || n < 1) return null; + return expectSubjectIsLength(call) ? "length.toBe(n>0)" : null; + } + default: + return null; + } +} + +export interface GuardFloorReport { + /** Every floor-asserting call reachable outside a skipped block. */ + floors: FloorShape[]; + /** True when the file calls a directory walker. */ + walksDirectories: boolean; +} + +export function analyseGuardSource( + fileName: string, + source: string, +): GuardFloorReport { + const sf = ts.createSourceFile( + fileName, + source, + ts.ScriptTarget.Latest, + true, + fileName.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); + const floors: FloorShape[] = []; + let walksDirectories = false; + + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const name = calleeName(node); + if (name && WALKERS.has(name)) walksDirectories = true; + const shape = floorShapeOf(node); + if (shape && !isInSkippedBlock(node)) floors.push(shape); + } + ts.forEachChild(node, visit); + }; + visit(sf); + + return { floors, walksDirectories }; +} From 3f5bcbd4412a65049e33c5d40fdf7ce6d9fcc1f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 11:17:44 +0200 Subject: [PATCH 3/6] fix(backup): carry four columns the restore was dropping A column-level round trip over every two-ended model found four values that left with the export and came back empty or re-stamped: - MenstrualCycle.absorbedIntoId: the disaster-recovery file carried the folded, soft-deleted start but not the start that absorbed it, so after a restore removing the absorbing start could no longer bring the folded one back. Carried in the disaster-recovery arm and written back only when the file carries the start it names. - Workout.externalSourceVersion: the source's version marker, stored so the sync can replay it unchanged, was in neither end. Carried beside metadata in the disaster-recovery arm. - NutrientIntakeDay.externalSourceVersion and createdAt: neither was selected, so a restored day lost its source marker and its first-arrival time. Carried in both arms. Files written before this change parse as before; every new field is optional on the way in. --- src/lib/cycle/backup.ts | 17 +++++++++++++++++ src/lib/export/full-backup-payload.ts | 7 +++++++ src/lib/export/records-backup.ts | 3 +++ src/lib/export/restore-backup.ts | 3 +++ src/lib/validations/backup.ts | 5 +++++ 5 files changed, 35 insertions(+) diff --git a/src/lib/cycle/backup.ts b/src/lib/cycle/backup.ts index 048e52f8c..1a216b812 100644 --- a/src/lib/cycle/backup.ts +++ b/src/lib/cycle/backup.ts @@ -64,6 +64,12 @@ export interface CycleBackupSection { isPredicted?: boolean; syncVersion?: number; deletedAt?: string | null; + /** + * Disaster recovery only: the start that folded this one in. A bare id with + * no foreign key, meaningful only because the same file carries the ids of + * the starts it names. + */ + absorbedIntoId?: string | null; createdAt?: string; updatedAt?: string; }>; @@ -214,6 +220,7 @@ export async function buildCycleBackupSection( isPredicted: c.isPredicted, syncVersion: c.syncVersion, deletedAt: c.deletedAt?.toISOString() ?? null, + absorbedIntoId: c.absorbedIntoId, createdAt: c.createdAt.toISOString(), updatedAt: c.updatedAt.toISOString(), } @@ -432,6 +439,12 @@ export async function restoreCycleData( // re-attach to their owning span. const cycleIdByStart = new Map(); const restoredCycleIds = new Set(); + // A folded start names the start that absorbed it. Kept only when that start + // is in the same file under the same id; a pointer at nothing would stop the + // folded row from ever coming back, which is what the column is for. + const carriedCycleIds = new Set( + payload.cycles.flatMap((c) => (c.id ? [c.id] : [])), + ); for (const c of payload.cycles) { const created = await tx.menstrualCycle.create({ data: { @@ -447,6 +460,10 @@ export async function restoreCycleData( isPredicted: c.isPredicted ?? false, syncVersion: c.syncVersion ?? 0, deletedAt: c.deletedAt ? new Date(c.deletedAt) : null, + absorbedIntoId: + c.absorbedIntoId && carriedCycleIds.has(c.absorbedIntoId) + ? c.absorbedIntoId + : null, ...(c.createdAt ? { createdAt: new Date(c.createdAt) } : {}), ...(c.updatedAt ? { updatedAt: new Date(c.updatedAt) } : {}), }, diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index d5a9137d7..a1c9396c3 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -887,6 +887,9 @@ export async function buildFullBackupPayload( // `source` is part of the composite PK, so it has to ride along or a // restore cannot tell a manual water entry from a synced day total. The // table carries no tombstone, so both purposes read the same shape. + // `externalSourceVersion` is the source's own version marker, stored so + // the sync can replay it back unchanged; `createdAt` is when the day + // first arrived. Neither can be rebuilt after a restore. prisma.nutrientIntakeDay.findMany({ where: { userId }, select: { @@ -895,6 +898,8 @@ export async function buildFullBackupPayload( amount: true, unit: true, source: true, + externalSourceVersion: true, + createdAt: true, }, orderBy: [{ day: "desc" }, { nutrient: "asc" }], }), @@ -1293,6 +1298,8 @@ export async function buildFullBackupPayload( amount: n.amount, unit: n.unit, source: n.source, + externalSourceVersion: n.externalSourceVersion, + createdAt: n.createdAt.toISOString(), })), }; diff --git a/src/lib/export/records-backup.ts b/src/lib/export/records-backup.ts index 2bb4cd278..99e97d032 100644 --- a/src/lib/export/records-backup.ts +++ b/src/lib/export/records-backup.ts @@ -142,6 +142,8 @@ export interface WorkoutBackupEntry { externalId: string | null; /** Canonical DR only; portable workout summaries keep their existing shape. */ metadata?: Prisma.JsonValue; + /** Canonical DR only, beside `metadata`: the source's own version marker. */ + externalSourceVersion?: string | null; } export interface DocumentBackupEntry { @@ -516,6 +518,7 @@ export async function buildRecordsBackupSection( createdAt: w.createdAt.toISOString(), updatedAt: w.updatedAt.toISOString(), ...(w.metadata == null ? {} : { metadata: w.metadata }), + externalSourceVersion: w.externalSourceVersion, } : {}), sportType: w.sportType, diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts index ff3bb1175..da1e3baca 100644 --- a/src/lib/export/restore-backup.ts +++ b/src/lib/export/restore-backup.ts @@ -2139,6 +2139,7 @@ export async function restoreBackup( pauseDurationSec: workout.pauseDurationSec ?? null, source: workout.source as never, externalId: workout.externalId ?? null, + externalSourceVersion: workout.externalSourceVersion ?? null, ...(workout.metadata == null ? {} : { metadata: toJson(workout.metadata) }), @@ -2161,6 +2162,8 @@ export async function restoreBackup( amount: n.amount, unit: n.unit, source: n.source, + externalSourceVersion: n.externalSourceVersion ?? null, + ...(n.createdAt ? { createdAt: new Date(n.createdAt) } : {}), })), }); } diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 06eb54aa7..e6412d98f 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -559,6 +559,7 @@ const cycleSpanSchema = z tz: z.string().nullable().optional(), syncVersion: z.number().int().optional(), deletedAt: isoDateTime.nullable().optional(), + absorbedIntoId: z.string().min(1).nullable().optional(), createdAt: isoDateTime.optional(), updatedAt: isoDateTime.optional(), }) @@ -691,6 +692,9 @@ const nutrientDaySchema = z // Closed set in app code rather than a DB enum; kept as a string here so a // future source does not make an old file unparseable. source: z.string().min(1), + // Absent from files written before v1.39.7. + externalSourceVersion: z.string().nullable().optional(), + createdAt: isoDateTime.optional(), }) .passthrough(); @@ -1641,6 +1645,7 @@ const workoutBackupSchema = z pauseDurationSec: z.number().int().nullable().optional(), source: z.enum(MeasurementSource), externalId: z.string().nullable().optional(), + externalSourceVersion: z.string().nullable().optional(), metadata: z.json().optional(), createdAt: isoDateTime.optional(), updatedAt: isoDateTime.optional(), From b7a3b0d33195e5255c6a73dc1518efc033afd71d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 11:17:44 +0200 Subject: [PATCH 4/6] test: round-trip every column of every two-ended model The round-trip test proved that a row of each two-ended model comes back, not that it comes back whole. A restore that writes the row with a column defaulted returns the right count and loses the value; the medication category went missing that way. The new case lists each model's columns from the client's data model, with optional/default/key attributes from schema.prisma (the two field lists must agree). Columns the fixture left empty get a value that cannot pass for a default, one column at a time so a refused value names its column. Then it exports, deletes the account, restores, and compares each column as a multiset of values: sealed columns by their plaintext, a legacy plaintext column together with its sealed twin, declared foreign keys by presence, primary keys, owner and @updatedAt excluded by rule. A column added tomorrow is compared automatically: it comes back, or it is named in COLUMN_EXCLUSIONS with a reason. Entries naming a column that no longer exists fail. A pure case shows the comparison catches a column it was never told about, and the run asserts at least 620 columns compared (632 today). Removing the workout source marker, a schedule label, the folded-cycle pointer or the nutrient createdAt from either end each fails the run naming that column. --- tests/integration/backup-column-audit.ts | 323 +++++++++++++++ tests/integration/backup-round-trip.test.ts | 433 +++++++++++++++++++- 2 files changed, 754 insertions(+), 2 deletions(-) create mode 100644 tests/integration/backup-column-audit.ts diff --git a/tests/integration/backup-column-audit.ts b/tests/integration/backup-column-audit.ts new file mode 100644 index 000000000..298eed297 --- /dev/null +++ b/tests/integration/backup-column-audit.ts @@ -0,0 +1,323 @@ +/** + * Column-level bookkeeping for the backup round trip. + * + * The round-trip test proves that a row of every two-ended model comes back. + * It does not prove the row comes back WHOLE: a restore that writes the row + * with a column defaulted returns the right count and loses the value. That is + * how the medication category went missing, and how the aggregation + * provenance did before it. + * + * So this reads every column of every two-ended model from the client's own + * data model (the generated DMMF projection Prisma ships inside the client), + * fills the ones the fixture left empty with a value that cannot be mistaken + * for a default, and compares each column before the export with the same + * column after the restore. A column added to a model tomorrow is in that list + * automatically, so it is compared automatically: it either comes back, or it + * is named in the exclusions with the reason it should not. + * + * Attributes the runtime data model does not carry (optional, list, default, + * id, foreign key) are read from `schema.prisma`, and the two field lists are + * required to agree, so neither source can quietly drift from the other. + */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import type { PrismaClient } from "@/generated/prisma/client"; +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { encrypt } from "@/lib/crypto"; +import { readDeclaredSchema } from "@/__tests__/helpers/prisma-schema-names"; + +const SCHEMA_PATH = resolve(__dirname, "../../prisma/schema.prisma"); + +export interface AuditColumn { + model: string; + name: string; + kind: "scalar" | "enum"; + type: string; + isList: boolean; + isOptional: boolean; + isId: boolean; + isUpdatedAt: boolean; + /** Named in an `@relation(fields: [...])` on this model. */ + isForeignKey: boolean; + /** The `@default(...)` argument as written, or null. */ + defaultText: string | null; +} + +export interface AuditModel { + name: string; + delegate: string; + columns: AuditColumn[]; + /** Fields of the primary key, `@id` or `@@id([...])`. */ + primaryKey: string[]; + /** To-one relations: field name -> target model. */ + parents: Record; +} + +interface RuntimeField { + name: string; + kind: "scalar" | "enum" | "object"; + type: string; +} + +interface RuntimeDataModel { + models: Record; +} + +function runtimeDataModel(prisma: PrismaClient): RuntimeDataModel { + const rdm = (prisma as unknown as { _runtimeDataModel?: RuntimeDataModel }) + ._runtimeDataModel; + if (!rdm?.models) { + throw new Error( + "the Prisma client no longer exposes its data model; the column audit " + + "cannot list columns and must not pass on an empty list", + ); + } + return rdm; +} + +interface SchemaFieldAttrs { + isList: boolean; + isOptional: boolean; + isId: boolean; + isUpdatedAt: boolean; + defaultText: string | null; +} + +/** Field attributes and block attributes of one `model` block. */ +function schemaModelAttrs(source: string, model: string) { + const start = source.search(new RegExp(`^model ${model} \\{`, "m")); + if (start === -1) throw new Error(`model ${model} not in schema.prisma`); + const end = source.indexOf("\n}", start); + const lines = source + .slice(start, end) + .split("\n") + .slice(1) + .filter((line) => !/^\s*\/\//.test(line)); + + const fields = new Map(); + const foreignKeys = new Set(); + let compoundId: string[] | null = null; + + for (const line of lines) { + const block = /^\s*@@id\(\[([^\]]+)\]/.exec(line); + if (block) { + compoundId = block[1].split(",").map((s) => s.trim()); + continue; + } + const m = /^\s*(\w+)\s+(\w+)(\[\])?(\?)?\s*(.*)$/.exec(line); + if (!m) continue; + const [, name, , list, optional, rest] = m; + const rel = /@relation\([^)]*fields:\s*\[([^\]]+)\]/.exec(rest); + if (rel) { + for (const fk of rel[1].split(",")) foreignKeys.add(fk.trim()); + } + const def = /@default\(((?:[^()]|\([^()]*\))*)\)/.exec(rest); + fields.set(name, { + isList: Boolean(list), + isOptional: Boolean(optional), + isId: /(^|\s)@id\b/.test(rest), + isUpdatedAt: /@updatedAt\b/.test(rest), + defaultText: def ? def[1].trim() : null, + }); + } + return { fields, foreignKeys, compoundId }; +} + +export function readAuditModels( + prisma: PrismaClient, + models: readonly string[], +): AuditModel[] { + const rdm = runtimeDataModel(prisma); + const source = readFileSync(SCHEMA_PATH, "utf8"); + + return models.map((name) => { + const runtime = rdm.models[name]; + if (!runtime) throw new Error(`${name} is not in the client data model`); + const attrs = schemaModelAttrs(source, name); + + const columns: AuditColumn[] = []; + const parents: Record = {}; + for (const field of runtime.fields) { + const a = attrs.fields.get(field.name); + if (!a) { + throw new Error( + `${name}.${field.name} is in the client data model but not in ` + + `schema.prisma; regenerate the client`, + ); + } + if (field.kind === "object") { + if (!a.isList) parents[field.name] = field.type; + continue; + } + columns.push({ + model: name, + name: field.name, + kind: field.kind, + type: field.type, + isList: a.isList, + isOptional: a.isOptional, + isId: a.isId, + isUpdatedAt: a.isUpdatedAt, + isForeignKey: attrs.foreignKeys.has(field.name), + defaultText: a.defaultText, + }); + } + + const primaryKey = + attrs.compoundId ?? columns.filter((c) => c.isId).map((c) => c.name); + if (primaryKey.length === 0) throw new Error(`${name} has no primary key`); + + return { + name, + delegate: name.charAt(0).toLowerCase() + name.slice(1), + columns, + primaryKey, + parents, + }; + }); +} + +/** + * The `where` that selects the rows one account owns: its own `userId` when + * the model has one, otherwise through the nearest to-one parent that does. + */ +export function ownerWhere( + models: ReadonlyMap, + prisma: PrismaClient, + model: string, + userId: string, + depth = 0, +): Record { + const rdm = runtimeDataModel(prisma); + const fields = rdm.models[model].fields; + if (fields.some((f) => f.name === "userId" && f.kind === "scalar")) { + return { userId }; + } + if (depth > 3) throw new Error(`no owner path for ${model}`); + const parents = + models.get(model)?.parents ?? + Object.fromEntries( + fields.filter((f) => f.kind === "object").map((f) => [f.name, f.type]), + ); + for (const [field, target] of Object.entries(parents)) { + try { + return { + [field]: ownerWhere(models, prisma, target, userId, depth + 1), + }; + } catch { + continue; + } + } + throw new Error(`no owner path for ${model}`); +} + +/** A comparable, order-independent rendering of one value. */ +export function normalise(value: unknown): string { + if (value === null || value === undefined) return "null"; + if (value instanceof Date) return `date:${value.toISOString()}`; + if (value instanceof Uint8Array) { + return `bytes:${Buffer.from(value).toString("base64")}`; + } + if (typeof value === "bigint") return `bigint:${value}`; + if (typeof value === "object" && "toFixed" in (value as object)) { + return `decimal:${String(value)}`; + } + return JSON.stringify(value, (_k, v) => + v && typeof v === "object" && !Array.isArray(v) + ? Object.fromEntries( + Object.entries(v as object).sort(([a], [b]) => a.localeCompare(b)), + ) + : v, + ); +} + +/** Is this value the column's declared default (or its empty state)? */ +export function isDefaultValue(column: AuditColumn, value: unknown): boolean { + if (value === null || value === undefined) return true; + if (column.isList && Array.isArray(value) && value.length === 0) return true; + const d = column.defaultText; + if (d === null) return false; + if (/^(now|uuid|cuid|autoincrement|dbgenerated)\(/.test(d)) return false; + const literal = d.replace(/^"(.*)"$/, "$1"); + if (column.type === "Json") { + try { + return normalise(value) === normalise(JSON.parse(literal)); + } catch { + return false; + } + } + if (column.isList && Array.isArray(value)) { + const items = d + .replace(/^\[|\]$/g, "") + .split(",") + .map((item) => item.trim().replace(/^"(.*)"$/, "$1")) + .filter((item) => item.length > 0); + return normalise(value.map(String)) === normalise(items); + } + return String(value) === literal; +} + +/** + * A value of the column's type that is not its default, for a column the + * fixture left empty. `overrides` supplies one where the type alone does not + * produce something the export and the restore accept. + */ +export function syntheticValue( + column: AuditColumn, + enumValues: ReadonlyMap, + overrides: Readonly>, +): unknown { + const key = `${column.model}.${column.name}`; + if (key in overrides) { + const v = overrides[key]; + return typeof v === "function" ? (v as () => unknown)() : v; + } + const tag = `rt-${column.model}-${column.name}`; + const sealed = /Encrypted$/.test(column.name); + let one: unknown; + if (column.kind === "enum") { + const values = enumValues.get(column.type) ?? []; + const literal = column.defaultText; + one = values.find((v) => v !== literal) ?? values[0]; + } else { + switch (column.type) { + case "String": + one = sealed ? encrypt(tag) : tag; + break; + case "Int": + one = column.defaultText === "3" ? 4 : 3; + break; + case "BigInt": + one = BigInt(5); + break; + case "Float": + case "Decimal": + one = column.defaultText === "2.5" ? 3.5 : 2.5; + break; + case "Boolean": + one = column.defaultText !== "true"; + break; + case "DateTime": + one = new Date("2026-06-30T12:34:56.789Z"); + break; + case "Json": + one = { roundTrip: tag }; + break; + case "Bytes": + // The codec most sealed byte columns share; a column on another one + // names its own in the overrides. + one = encryptToBytes(tag); + break; + default: + throw new Error(`no synthetic value for ${key} (${column.type})`); + } + } + return column.isList ? [one] : one; +} + +export function enumValuesFromSchema(): Map { + return new Map( + readDeclaredSchema(SCHEMA_PATH).enums.map((e) => [e.name, e.values]), + ); +} diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 1046fee29..c955cb2d5 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -43,13 +43,15 @@ * is a thing a restore could get wrong while returning the right number of * rows, which is the only test worth writing here. */ +import { createHash } from "node:crypto"; + import { beforeEach, describe, expect, it, vi } from "vitest"; process.env.ENCRYPTION_KEY ??= "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; import type { PrismaClient } from "@/generated/prisma/client"; -import { encrypt, encryptBytes } from "@/lib/crypto"; +import { decrypt, decryptBytes, encrypt, encryptBytes } from "@/lib/crypto"; import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { readNote } from "@/lib/crypto/note-cipher"; import { @@ -69,11 +71,25 @@ import { } from "@/lib/medication-category"; import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker"; import { decryptNoteFromBytes } from "@/lib/labs/store"; -import { decryptContextFromBytes } from "@/lib/labs/biomarker-store"; +import { + decryptContextFromBytes, + encryptContextToBytes, +} from "@/lib/labs/biomarker-store"; import { legacyStreamedBlobFrom } from "@/__tests__/helpers/legacy-backup-blob"; import { streamFullBackupJson } from "@/lib/export/full-backup-stream"; import { TWO_ENDED_MODELS, type TwoEndedModel } from "@/lib/export/backup-plan"; +import { parseOnboardingSteps } from "@/lib/onboarding/needs"; import { POST } from "./restore-job-driver"; +import { + type AuditColumn, + type AuditModel, + enumValuesFromSchema, + isDefaultValue, + normalise, + ownerWhere, + readAuditModels, + syntheticValue, +} from "./backup-column-audit"; import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables } from "./setup"; @@ -3212,3 +3228,416 @@ describe("every model the plan claims two-ended survives a real restore", () => }); } }); + +/** + * Columns the restore is right not to bring back verbatim, by `Model.column`. + * Every other column of every two-ended model must come back with the value + * it left with. An entry here whose column no longer exists fails the run. + */ +const COLUMN_EXCLUSIONS: Readonly> = { + "MeasurementReminder.lastNotifiedAt": + "operational repeat cursor, documented on the column as not in the backup: a restored reminder re-reminds at most once, on its next notify hour", + // When a join row was written. Nothing reads these: the links are read for + // which tag, symptom or context applies, never for when the row appeared, + // and the parent entry carries its own timestamps, which do travel. + "MoodContext.createdAt": + "insert stamp of the per-entry context row; no reader, the mood entry's own timestamps travel", + "MoodEntryTagLink.createdAt": + "insert stamp of a tag link; no reader, the mood entry's own timestamps travel", + "CycleSymptomLink.createdAt": + "insert stamp of a symptom link; no reader, the day log's own timestamps travel", + "IllnessSymptomLink.createdAt": + "insert stamp of a symptom link; no reader, the illness day log's own timestamps travel", +}; + +/** + * Columns every reader parses through one function before using, compared + * through that same function: the restore writes the parsed form. + */ +const READ_THROUGH: Readonly unknown>> = { + "OnboardingRecord.stepsJson": parseOnboardingSteps, +}; + +/** + * Values for columns the fixture leaves empty where the type alone does not + * produce one the export and the restore accept. + */ +const FILL_OVERRIDES: Readonly> = { + "MedicationSchedule.rrule": "FREQ=WEEKLY;BYDAY=MO,TH", + "InboundDocument.sourceInstance": "https://paperless.example.test", + // The digest of the fixture's own document bytes, so a restore that checks + // it against the content finds them consistent. + "InboundDocument.contentSha256": createHash("sha256") + .update("round-trip document bytes") + .digest("hex"), + "Biomarker.contextEncrypted": () => + encryptContextToBytes("fasted, morning draw"), + "MoodEntry.tags": JSON.stringify(["round-trip-tag"]), + "CorrelationPattern.qValue": 0.04, + "CorrelationPattern.dismissedEvidenceHash": "ab".repeat(32), +}; + +/** + * Rows for the columns a CHECK constraint ties to a shape the base fixture + * does not have: a stage only on a sleep reading, a context only on a glucose + * reading, a rolling interval only on a schedule without a calendar rule. + */ +async function seedColumnShapes(prisma: PrismaClient): Promise { + await prisma.measurement.create({ + data: { + userId: OWNER_ID, + type: "BLOOD_GLUCOSE", + value: 5.4, + unit: "mmol/L", + measuredAt: AT("2026-07-03T06:45:00.000Z"), + source: "MANUAL", + glucoseContext: "FASTING", + }, + }); + await prisma.measurement.create({ + data: { + userId: OWNER_ID, + type: "SLEEP_DURATION", + value: 95, + unit: "min", + measuredAt: AT("2026-07-03T02:00:00.000Z"), + source: "APPLE_HEALTH", + externalId: "round-trip-deep-sleep", + sleepStage: "DEEP", + }, + }); + // A start logged out of order and folded into the one before it: soft + // deleted, and pointing at the start that absorbed it so that removing that + // start can bring this one back. + const absorbing = await prisma.menstrualCycle.findFirstOrThrow({ + where: { userId: OWNER_ID, startDate: "2026-06-01" }, + }); + await prisma.menstrualCycle.create({ + data: { + userId: OWNER_ID, + startDate: "2026-06-03", + deletedAt: AT("2026-06-04T07:00:00.000Z"), + absorbedIntoId: absorbing.id, + }, + }); + const medication = await prisma.medication.findFirstOrThrow({ + where: { userId: OWNER_ID }, + }); + await prisma.medicationSchedule.create({ + data: { + medicationId: medication.id, + windowStart: "20:00", + windowEnd: "21:00", + label: "Every third evening", + rollingIntervalDays: 3, + }, + }); +} + +type Delegate = { + findMany(args: { + where: Record; + }): Promise[]>; + update(args: { + where: Record; + data: Record; + }): Promise; +}; + +function delegateOf(prisma: PrismaClient, model: AuditModel): Delegate { + return (prisma as unknown as Record)[model.delegate]; +} + +function primaryKeyWhere( + model: AuditModel, + row: Record, +): Record { + if (model.primaryKey.length === 1) { + return { [model.primaryKey[0]]: row[model.primaryKey[0]] }; + } + return { + [model.primaryKey.join("_")]: Object.fromEntries( + model.primaryKey.map((k) => [k, row[k]]), + ), + }; +} + +/** How a column is compared, or why it is not. */ +function comparison( + column: AuditColumn, +): "value" | "presence" | { excluded: string } { + const key = `${column.model}.${column.name}`; + if (key in COLUMN_EXCLUSIONS) return { excluded: COLUMN_EXCLUSIONS[key] }; + if (column.isId) { + return { excluded: "primary key; the restore may mint a fresh one" }; + } + if (column.name === "userId") { + return { excluded: "owner; re-derived from the restoring account" }; + } + if (column.isUpdatedAt) { + return { excluded: "@updatedAt; Prisma stamps it on the restore's write" }; + } + // A declared foreign key may be re-pointed at a row the restore minted, so + // it is held to being set where it was set, not to the same id. + if (column.isForeignKey) return "presence"; + return "value"; +} + +/** Plaintext behind a sealed value, or the value itself when it is not one. */ +function opened(value: unknown): string { + if (value === null || value === undefined) return "null"; + if (typeof value === "string") { + try { + return `text:${decrypt(value)}`; + } catch { + return `text:${value}`; + } + } + if (value instanceof Uint8Array) { + for (const open of BYTE_OPENERS) { + try { + return `text:${open(value)}`; + } catch { + continue; + } + } + } + return normalise(value); +} + +/** Every codec a sealed byte column is written with. */ +const BYTE_OPENERS: ReadonlyArray<(value: Uint8Array) => string> = [ + decryptFromBytes, + decryptNoteFromBytes, + decryptContextFromBytes, + (value) => decryptBytes(Buffer.from(value)).toString("base64"), +]; + +/** + * A legacy plaintext column beside its sealed twin (`note` / `noteEncrypted`) + * is one value in two places: the restore seals what it finds in either, and + * clears the plaintext. So the pair is compared as the value a reader sees. + */ +function readerValue( + row: Record, + column: AuditColumn, +): string { + const parse = READ_THROUGH[`${column.model}.${column.name}`]; + if (parse) return normalise(parse(row[column.name])); + const twin = `${column.name}Encrypted`; + if (twin in row) { + const sealed = opened(row[twin]); + return sealed !== "null" ? sealed : opened(row[column.name]); + } + return opened(row[column.name]); +} + +function columnValues( + rows: Record[], + column: AuditColumn, + how: "value" | "presence", +): string[] { + return rows + .map((row) => + how === "presence" + ? row[column.name] === null || row[column.name] === undefined + ? "null" + : "set" + : readerValue(row, column), + ) + .sort(); +} + +async function snapshot( + prisma: PrismaClient, + models: AuditModel[], + wheres: Map>, +): Promise[]>> { + const out = new Map[]>(); + for (const model of models) { + out.set( + model.name, + await delegateOf(prisma, model).findMany({ + where: wheres.get(model.name)!, + }), + ); + } + return out; +} + +/** + * Columns whose value differs between two snapshots, by `Model.column`. Pure, + * so the test can show it catches a column it was never told about. + */ +function lostColumns( + models: AuditModel[], + before: Map[]>, + after: Map[]>, +): { lost: string[]; compared: number } { + const lost: string[] = []; + let compared = 0; + for (const model of models) { + for (const column of model.columns) { + const how = comparison(column); + if (typeof how === "object") continue; + const plain = column.name.replace(/Encrypted$/, ""); + if ( + plain !== column.name && + model.columns.some((c) => c.name === plain) + ) { + continue; // compared through its plaintext twin, see readerValue + } + compared += 1; + const was = columnValues(before.get(model.name) ?? [], column, how); + const is = columnValues(after.get(model.name) ?? [], column, how); + if (normalise(was) !== normalise(is)) { + lost.push(`${model.name}.${column.name}`); + } + } + } + return { lost: lost.sort(), compared }; +} + +describe("every column of every two-ended model survives a real restore", () => { + it("brings each column back with the value it left with", async () => { + const prisma = getPrismaClient(); + await seedAdminSession(prisma); + await createOwner(prisma); + // A catalogue row the fixture adds, which survives both the account delete + // and the per-test truncation; left behind by the first test in this file. + await prisma.illnessSymptom.deleteMany({ + where: { key: "round_trip_cough" }, + }); + await seedEveryTwoEndedModel(prisma); + await seedColumnShapes(prisma); + + const models = readAuditModels(prisma, TWO_ENDED_MODELS); + const byName = new Map(models.map((m) => [m.name, m])); + const wheres = new Map( + models.map((m) => [m.name, ownerWhere(byName, prisma, m.name, OWNER_ID)]), + ); + const enumValues = enumValuesFromSchema(); + + // Fill what the fixture left empty, so an empty column cannot come back + // empty and read as carried. One column at a time on one row, so a value + // the database refuses names its column instead of failing the batch. + const unfillable: string[] = []; + const seededRows = await snapshot(prisma, models, wheres); + for (const model of models) { + const rows = seededRows.get(model.name)!; + expect(rows.length, `${model.name} seeded no row`).toBeGreaterThan(0); + for (const column of model.columns) { + if (comparison(column) !== "value") continue; + // Filling it would turn the fixture's row into a tombstone, and which + // models carry tombstones is a row decision, not a column one. + if (column.name === "deletedAt") continue; + if (rows.some((row) => !isDefaultValue(column, row[column.name]))) { + continue; + } + try { + await delegateOf(prisma, model).update({ + where: primaryKeyWhere(model, rows[0]), + data: { + [column.name]: syntheticValue(column, enumValues, FILL_OVERRIDES), + }, + }); + } catch (error) { + unfillable.push( + `${model.name}.${column.name}: ${String(error).split("\n").slice(-3).join(" ").slice(0, 200)}`, + ); + } + } + } + expect(unfillable, "columns the fill could not set").toEqual([]); + + const before = await snapshot(prisma, models, wheres); + + const exportedAt = new Date("2026-08-01T00:00:00.000Z"); + let json = ""; + await streamFullBackupJson( + prisma, + OWNER_ID, + (chunk) => { + json += chunk; + }, + { purpose: "disaster-recovery", exportedAt }, + ); + await prisma.user.delete({ where: { id: OWNER_ID } }); + await createOwner(prisma); + const backup = await prisma.dataBackup.create({ + data: { + userId: OWNER_ID, + type: "COLUMN_ROUND_TRIP", + data: await legacyStreamedBlobFrom(async (write) => { + await write(json); + }), + }, + }); + const response = await POST( + new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }) as never, + { params: Promise.resolve({ id: backup.id }) }, + ); + const body = await response.json(); + expect(response.status, JSON.stringify(body)).toBe(200); + + const after = await snapshot(prisma, models, wheres); + const { lost, compared } = lostColumns(models, before, after); + + // A comparison over no columns agrees with everything. 632 at the time of + // writing; the headroom is for a column dropped by a migration, not for a + // data model that stopped being read. + expect(compared).toBeGreaterThanOrEqual(620); + expect( + lost, + "these columns left with a value and came back without it. Carry each " + + "through the export and the restore, or name it in COLUMN_EXCLUSIONS " + + "with the reason the restore is right to drop it", + ).toEqual([]); + }); + + it("notices a column nobody told it about", () => { + const model: AuditModel = { + name: "Probe", + delegate: "probe", + primaryKey: ["id"], + parents: {}, + columns: [ + { + model: "Probe", + name: "addedTomorrow", + kind: "scalar", + type: "String", + isList: false, + isOptional: true, + isId: false, + isUpdatedAt: false, + isForeignKey: false, + defaultText: null, + }, + ], + }; + const before = new Map([["Probe", [{ id: "a", addedTomorrow: "kept" }]]]); + const after = new Map([["Probe", [{ id: "b", addedTomorrow: null }]]]); + expect(lostColumns([model], before, after)).toEqual({ + lost: ["Probe.addedTomorrow"], + compared: 1, + }); + }); + + it("names no exclusion for a column that no longer exists", () => { + const models = readAuditModels(getPrismaClient(), TWO_ENDED_MODELS); + const known = new Set( + models.flatMap((m) => m.columns.map((c) => `${m.name}.${c.name}`)), + ); + const stale = [ + ...Object.keys(COLUMN_EXCLUSIONS), + ...Object.keys(READ_THROUGH), + ...Object.keys(FILL_OVERRIDES), + ].filter((key) => !known.has(key)); + expect(stale).toEqual([]); + }); +}); From d1a7406ff7f610381b8f14738bac69f90584ac2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 11:24:25 +0200 Subject: [PATCH 5/6] test: pin the nutrient source marker and arrival time in the payload builder test --- src/lib/export/__tests__/full-backup-payload.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 6b54c2c4c..e0dcf020b 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -361,6 +361,8 @@ function makePrisma() { amount: 1500, unit: "ml", source: "MANUAL", + externalSourceVersion: null, + createdAt: new Date("2026-07-19T20:15:00.000Z"), }, ]), }, @@ -705,6 +707,8 @@ describe("buildFullBackupPayload disaster-recovery mode", () => { amount: 1500, unit: "ml", source: "MANUAL", + externalSourceVersion: null, + createdAt: "2026-07-19T20:15:00.000Z", }, ], }); From 6725110b25f5ebba85f01608ea23000dbd27b635 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 11:34:19 +0200 Subject: [PATCH 6/6] chore(openapi): regenerate for the backup fields that now round-trip --- docs/api/openapi.yaml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1850562a2..fee198522 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -35293,6 +35293,11 @@ components: - type: string minLength: 1 - type: "null" + absorbedIntoId: + anyOf: + - type: string + minLength: 1 + - type: "null" createdAt: type: string minLength: 1 @@ -36145,6 +36150,10 @@ components: anyOf: - type: string - type: "null" + externalSourceVersion: + anyOf: + - type: string + - type: "null" metadata: $ref: "#/components/schemas/__schema0" createdAt: @@ -36398,6 +36407,13 @@ components: source: type: string minLength: 1 + externalSourceVersion: + anyOf: + - type: string + - type: "null" + createdAt: + type: string + minLength: 1 required: - day - nutrient