mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
Merge branch 'fix/v1437-data' into release/v1.43.7
This commit is contained in:
commit
dee6a27ae4
100 files changed
+2314
-506
No files matched your search
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* No client component reaches a server-only module.
|
||||
*
|
||||
* A `"use client"` file ships to the browser together with everything it
|
||||
* imports, transitively. Two modules did reach the server side that way: the
|
||||
* side-effect taxonomy imported its enums from the Prisma CLIENT entry, and
|
||||
* the recurrence engine the medication wizard previews schedules with
|
||||
* imported the logging context, which brings `node:async_hooks`, `node:crypto`
|
||||
* and `node:os` with it. The build tolerated both; neither belongs in a
|
||||
* browser bundle, and the next such import could be the database client.
|
||||
*
|
||||
* The project has no `server-only` package to mark modules with, so this
|
||||
* walks the graph instead: from every `"use client"` file, through every
|
||||
* value import (`import type` is erased and not followed), and fails on any
|
||||
* reachable import of the database client, the Prisma client entry (the
|
||||
* `enums` entry is browser-safe and allowed), `next/headers`, the job queue,
|
||||
* or a Node built-in. The failure prints the chain, so the fix is obvious.
|
||||
*
|
||||
* Limits, stated: specifiers are resolved the way the app resolves `@/…` and
|
||||
* relative paths; a re-export through a package outside `src` is not
|
||||
* followed. The floors below fail the guard if the walk finds too little to
|
||||
* mean anything.
|
||||
*/
|
||||
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
const NODE_BUILTIN =
|
||||
/^(node:|(fs|path|crypto|child_process|net|tls|zlib|stream|os|async_hooks|worker_threads|http|https|dns)(\/|$))/;
|
||||
|
||||
function isServerOnly(spec: string, file: string | null): boolean {
|
||||
if (file) {
|
||||
const rel = file.slice(SRC.length + 1);
|
||||
return rel === "lib/db.ts" || rel.startsWith("generated/prisma/client");
|
||||
}
|
||||
return (
|
||||
NODE_BUILTIN.test(spec) ||
|
||||
spec === "next/headers" ||
|
||||
spec === "pg-boss" ||
|
||||
spec === "pg" ||
|
||||
spec.startsWith("@prisma/")
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSpec(spec: string, from: string): string | null {
|
||||
let base: string;
|
||||
if (spec.startsWith("@/")) base = join(SRC, spec.slice(2));
|
||||
else if (spec.startsWith(".")) base = resolve(dirname(from), spec);
|
||||
else return null;
|
||||
for (const c of [
|
||||
`${base}.ts`,
|
||||
`${base}.tsx`,
|
||||
join(base, "index.ts"),
|
||||
join(base, "index.tsx"),
|
||||
]) {
|
||||
if (existsSync(c) && statSync(c).isFile()) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Value imports only: `import type` and all-`type` named lists are erased. */
|
||||
function valueImports(source: string): string[] {
|
||||
const out: string[] = [];
|
||||
const re =
|
||||
/(?:^|\n)\s*(?:import|export)\s+(type\s+)?([\s\S]{0,600}?)\s*from\s*["']([^"']+)["']/g;
|
||||
for (const m of source.matchAll(re)) {
|
||||
if (m[1]) continue;
|
||||
const clause = m[2].trim();
|
||||
if (/^\{[^}]*\}$/.test(clause)) {
|
||||
const names = clause
|
||||
.replace(/[{}\s]/g, " ")
|
||||
.split(",")
|
||||
.map((n) => n.trim())
|
||||
.filter(Boolean);
|
||||
if (names.length > 0 && names.every((n) => n.startsWith("type ")))
|
||||
continue;
|
||||
}
|
||||
out.push(m[3]);
|
||||
}
|
||||
for (const m of source.matchAll(/(?:^|\n)\s*import\s+["']([^"']+)["']/g))
|
||||
out.push(m[1]);
|
||||
for (const m of source.matchAll(/import\(\s*["']([^"']+)["']\s*\)/g))
|
||||
out.push(m[1]);
|
||||
return out;
|
||||
}
|
||||
|
||||
function walkFromClients() {
|
||||
const files = walkSourceFiles(SRC, { floor: 2000 })
|
||||
.filter((f) => !f.startsWith("generated/") && !f.includes("__tests__/"))
|
||||
.filter((f) => !/\.test\.tsx?$/.test(f))
|
||||
.map((f) => join(SRC, f));
|
||||
const clients = files.filter((f) =>
|
||||
/^\s*["']use client["']/.test(readFileSync(f, "utf8")),
|
||||
);
|
||||
const parent = new Map<string, string | null>(clients.map((c) => [c, null]));
|
||||
const queue = [...clients];
|
||||
const violations: string[] = [];
|
||||
for (let i = 0; i < queue.length; i++) {
|
||||
const file = queue[i];
|
||||
for (const spec of valueImports(readFileSync(file, "utf8"))) {
|
||||
const target = resolveSpec(spec, file);
|
||||
if (isServerOnly(spec, target)) {
|
||||
const chain = [spec];
|
||||
for (let at: string | null = file; at; at = parent.get(at) ?? null) {
|
||||
chain.unshift(at.slice(SRC.length + 1));
|
||||
}
|
||||
violations.push(chain.join(" -> "));
|
||||
continue;
|
||||
}
|
||||
if (target && !parent.has(target)) {
|
||||
parent.set(target, file);
|
||||
queue.push(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { clients: clients.length, reachable: parent.size, violations };
|
||||
}
|
||||
|
||||
describe("client bundle", () => {
|
||||
const result = walkFromClients();
|
||||
|
||||
it("walks enough of the tree to mean something", () => {
|
||||
expect(result.clients).toBeGreaterThan(500);
|
||||
expect(result.reachable).toBeGreaterThan(900);
|
||||
});
|
||||
|
||||
it("reaches no server-only module from a client component", () => {
|
||||
expect(result.violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* The cached AI insights payload is read and written in one place.
|
||||
*
|
||||
* Since v1.43.7 the payload is sealed in `User.insightsCachedTextEncrypted`
|
||||
* and the readable `insightsCachedText` survives only as a fallback for rows
|
||||
* the backfill has not reached. Two columns that must move together are a
|
||||
* standing invitation to move one: a clear written as
|
||||
* `{ insightsCachedText: null }` — the shape every clear had until this
|
||||
* release — would now leave the whole sealed briefing behind, after a consent
|
||||
* withdrawal of all places. So the names are spelled in
|
||||
* `src/lib/insights/cached-text-cipher.ts`, and every other file goes through
|
||||
* its select, write, clear and open helpers.
|
||||
*
|
||||
* The allowlist below is the set of files with a reason to spell the legacy
|
||||
* name anyway, each stated. A file that joins it has to say why here.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
const ALLOWED: Readonly<Record<string, string>> = {
|
||||
"lib/insights/cached-text-cipher.ts": "the one module that owns the columns",
|
||||
"lib/jobs/free-text-encryption-backfill.ts":
|
||||
"the backfill that seals the legacy column",
|
||||
"lib/export/backup-plan.ts":
|
||||
"the backup classification names every User column",
|
||||
"lib/data-wipe/wipe-plan.ts":
|
||||
"the wipe reset names every User column it resets, and resets both",
|
||||
"lib/dashboard/snapshot.ts":
|
||||
"the snapshot builder's input field carries the OPENED text, not the column",
|
||||
"lib/dashboard/snapshot-read.ts":
|
||||
"fills that input field from openInsightsCachedText",
|
||||
};
|
||||
|
||||
/** The legacy name, not its sealed sibling, outside comment lines. */
|
||||
const LEGACY_NAME = /\binsightsCachedText\b(?!Encrypted)/;
|
||||
|
||||
function codeLines(source: string): string[] {
|
||||
return source.split("\n").filter((line) => {
|
||||
const t = line.trim();
|
||||
return !(t.startsWith("*") || t.startsWith("//") || t.startsWith("/*"));
|
||||
});
|
||||
}
|
||||
|
||||
function filesSpellingTheLegacyName(): string[] {
|
||||
return walkSourceFiles(SRC, { floor: 2000 })
|
||||
.filter((f) => !f.startsWith("generated/"))
|
||||
.filter((f) => !f.includes("__tests__/") && !/\.test\.tsx?$/.test(f))
|
||||
.filter((f) =>
|
||||
codeLines(readFileSync(join(SRC, f), "utf8")).some((l) =>
|
||||
LEGACY_NAME.test(l),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe("insights cache column access", () => {
|
||||
const found = filesSpellingTheLegacyName();
|
||||
|
||||
it("is confined to the cipher module and the stated exceptions", () => {
|
||||
const strays = found.filter((f) => !(f in ALLOWED));
|
||||
expect(strays).toEqual([]);
|
||||
});
|
||||
|
||||
it("still finds every stated exception, so the matcher is live", () => {
|
||||
expect(found.length).toBeGreaterThan(5);
|
||||
expect([...found].sort()).toEqual(Object.keys(ALLOWED).sort());
|
||||
});
|
||||
});
|
||||
@@ -63,11 +63,23 @@ describe("User column backup classification", () => {
|
||||
expect(sealedSettings.sort()).toEqual([...SEALED_SETTINGS].sort());
|
||||
});
|
||||
|
||||
// A cache this host wrote, sealed because its content is model prose about
|
||||
// the person: OPERATIONAL like its readable predecessor, and like every
|
||||
// other class but SETTING it never enters the file. Named here so a sealed
|
||||
// column cannot drift out of CREDENTIAL without someone saying why.
|
||||
const SEALED_OPERATIONAL = ["insightsCachedTextEncrypted"];
|
||||
|
||||
it("never classifies a sealed column other than the sealed settings as anything but a credential", () => {
|
||||
const sealed = columns.filter(
|
||||
(c) => /Encrypted$/.test(c) && !SEALED_SETTINGS.includes(c),
|
||||
(c) =>
|
||||
/Encrypted$/.test(c) &&
|
||||
!SEALED_SETTINGS.includes(c) &&
|
||||
!SEALED_OPERATIONAL.includes(c),
|
||||
);
|
||||
expect(sealed.length).toBeGreaterThan(20);
|
||||
expect(sealed.filter((c) => classified[c] !== "CREDENTIAL")).toEqual([]);
|
||||
for (const c of SEALED_OPERATIONAL) {
|
||||
expect(classified[c]).toBe("OPERATIONAL");
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user