mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
refactor(measurements): move the batch ingest out of its route
postBatch was one 1255-line function. The route keeps authentication, the rate limit, the body, the entry cap, the schema and the bridge-source refusal; the ingest moves verbatim into lib/measurements/batch-ingest/, one module per stage in the order they always ran: the first pass (prepare), the measurement writes with their prefilters and set-based insert, the hourly sums, the sync checkpoint, the audit and wide-event report, and the cache and arrival tail. The route is 274 lines; no function in the folder is over 200. The parity walk recorded before the move (measurements-batch.parity) is unchanged, as is the set-insert golden. The guards that read the route for the per-entry external-id check, the plausibility validator, the write tail, the dense-read bound and the unit audit now read the service files.
This commit is contained in:
1 parent
3df80389ab
commit
25c3c050ee
14 files changed
+1831
-1323
No files matched your search
@@ -48,7 +48,7 @@ import {
|
||||
convertHkValue,
|
||||
isConvertibleHkUnit,
|
||||
} from "@/lib/measurements/hk-units";
|
||||
import { readSource } from "@/__tests__/helpers/source-files";
|
||||
import { readSource, readSourceFolder } from "@/__tests__/helpers/source-files";
|
||||
|
||||
const ENTRIES = Object.values(APPLE_HEALTH_TYPE_MAP);
|
||||
|
||||
@@ -194,10 +194,17 @@ describe("Apple Health mapping — the record's own unit (issue #944)", () => {
|
||||
"utf8",
|
||||
);
|
||||
expect(archive).toContain(optIn);
|
||||
const batch = readSource("src/app/api/measurements/batch/route.ts", [
|
||||
"export const POST",
|
||||
"hkIdentifier",
|
||||
]);
|
||||
// The route parses; its ingest maps the entries in the service folder.
|
||||
const batch = [
|
||||
readSource("src/app/api/measurements/batch/route.ts", [
|
||||
"export const POST",
|
||||
"hkIdentifier",
|
||||
]),
|
||||
...readSourceFolder("src/lib/measurements/batch-ingest", {
|
||||
floor: 5,
|
||||
}).map(([, text]) => text),
|
||||
].join("\n");
|
||||
expect(batch).toContain("mapAppleHealthEntry(");
|
||||
expect(
|
||||
batch,
|
||||
"the batch route opted into record-unit conversion",
|
||||
|
||||
@@ -64,6 +64,9 @@ const SCOPED_DIRS = [
|
||||
"lib/export",
|
||||
"lib/targets/",
|
||||
"lib/doctor-report",
|
||||
// Request-handler logic moved out of its route into a service keeps the
|
||||
// route's scope.
|
||||
"lib/measurements/batch-ingest/",
|
||||
];
|
||||
|
||||
/** Types a device writes at sampling rate rather than a few times a day. */
|
||||
@@ -103,7 +106,7 @@ const ALLOWED: Record<string, string> = {
|
||||
"Health-score inputs: steps (drained to one row per day nightly), sleep, waist, weight, blood pressure, fasting glucose only.",
|
||||
'app/api/analytics/route.ts::"BLOOD_GLUCOSE"':
|
||||
"The 30-day per-context glucose summary, the same window and bound as the dashboard and coach glucose panels: at a sensor's fixed rate, below 45 000 rows.",
|
||||
"app/api/measurements/batch/route.ts::p.row.type as MeasurementType":
|
||||
"lib/measurements/batch-ingest/write-measurements.ts::p.row.type as MeasurementType":
|
||||
"Cross-source merge probe: one ±tolerance window around each posted reading's instant, so the rows found are bounded by the batch size.",
|
||||
"app/api/measurements/route.ts::(shorthand)":
|
||||
"Cross-source merge probe for one posted sleep segment: SLEEP_DURATION in a ±tolerance window around its end.",
|
||||
|
||||
@@ -394,17 +394,28 @@ describe("the batch surfaces refuse per entry, never whole-batch", () => {
|
||||
// Each of these drains a client sync queue. A schema-level refusal here
|
||||
// would stop a whole batch of good rows because of one bad one, which is
|
||||
// worse than the duplicate it prevents.
|
||||
const BATCH_ROUTES = [
|
||||
"app/api/measurements/batch/route.ts",
|
||||
"app/api/mood-entries/bulk/route.ts",
|
||||
"app/api/medications/intake/bulk/route.ts",
|
||||
"app/api/workouts/batch/route.ts",
|
||||
"app/api/cycle/day-logs/bulk/route.ts",
|
||||
"app/api/import/route.ts",
|
||||
];
|
||||
//
|
||||
// A surface whose ingest moved into a service names the service files
|
||||
// beside the route: the route parses, the service refuses per entry.
|
||||
const BATCH_SURFACES: Record<string, readonly string[]> = {
|
||||
"app/api/measurements/batch/route.ts": [
|
||||
"lib/measurements/batch-ingest/prepare.ts",
|
||||
"lib/measurements/batch-ingest/ingest.ts",
|
||||
],
|
||||
"app/api/mood-entries/bulk/route.ts": [],
|
||||
"app/api/medications/intake/bulk/route.ts": [],
|
||||
"app/api/workouts/batch/route.ts": [],
|
||||
"app/api/cycle/day-logs/bulk/route.ts": [],
|
||||
"app/api/import/route.ts": [],
|
||||
};
|
||||
const BATCH_ROUTES = Object.keys(BATCH_SURFACES);
|
||||
|
||||
/** The route and the service files that carry its per-entry ingest. */
|
||||
const surface = (rel: string): string =>
|
||||
[rel, ...BATCH_SURFACES[rel]].map((file) => body(file)).join("\n");
|
||||
|
||||
it.each(BATCH_ROUTES)("%s classifies each entry itself", (rel) => {
|
||||
const source = body(rel);
|
||||
const source = surface(rel);
|
||||
expect(source).toContain("classifyExternalId");
|
||||
expect(source).toContain("unstableExternalIdMeta");
|
||||
});
|
||||
@@ -414,7 +425,7 @@ describe("the batch surfaces refuse per entry, never whole-batch", () => {
|
||||
(rel) => {
|
||||
// The JSON importer has no per-entry status envelope — it reports a
|
||||
// skipped COUNT — so it is the one batch surface without the code.
|
||||
expect(body(rel)).toContain("UNSTABLE_EXTERNAL_ID_REASON");
|
||||
expect(surface(rel)).toContain("UNSTABLE_EXTERNAL_ID_REASON");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -73,10 +73,13 @@ const WRITERS: Record<string, Disposition> = {
|
||||
validatedIn: "lib/validations/measurement.ts",
|
||||
},
|
||||
"app/api/import/route.ts": { kind: "validator" },
|
||||
// v1.44 — the set-based insert writes rows that already passed the
|
||||
// route's `validateMeasurementRange` per entry; everything else goes
|
||||
// through the reconciler, which gates itself.
|
||||
"app/api/measurements/batch/route.ts": { kind: "validator" },
|
||||
// The batch ingest's set-based insert writes rows that already passed
|
||||
// `validateMeasurementRange` per entry in its first pass; everything else
|
||||
// goes through the reconciler, which gates itself.
|
||||
"lib/measurements/batch-ingest/write-measurements.ts": {
|
||||
kind: "upstream",
|
||||
validatedIn: "lib/measurements/batch-ingest/prepare.ts",
|
||||
},
|
||||
"lib/mcp/writes.ts": { kind: "validator" },
|
||||
"lib/measurements/create-from-telegram.ts": { kind: "validator" },
|
||||
"lib/measurements/import-apple-health-export.ts": { kind: "validator" },
|
||||
|
||||
@@ -32,6 +32,13 @@ const WRITE_RE =
|
||||
/** Runs `afterMeasurementMutation` on the rows it wrote. */
|
||||
const TAIL = "runs afterMeasurementMutation";
|
||||
|
||||
/**
|
||||
* Writes here; the module named runs `afterMeasurementMutation` on the same
|
||||
* rows (a service split into stages, whose write and tail are siblings).
|
||||
*/
|
||||
const TAIL_VIA_PREFIX = "runs afterMeasurementMutation via ";
|
||||
const TAIL_VIA = (file: string) => `${TAIL_VIA_PREFIX}${file}`;
|
||||
|
||||
/**
|
||||
* Every file with a measurement write, and how it keeps the two readers
|
||||
* current. A file listed here must still contain a write (a stale entry
|
||||
@@ -44,7 +51,9 @@ const WRITE_SITES: Record<string, string> = {
|
||||
"app/api/measurements/bulk-delete/route.ts": TAIL,
|
||||
"app/api/measurements/by-external-ids/route.ts": TAIL,
|
||||
"app/api/measurements/restore/route.ts": TAIL,
|
||||
"app/api/measurements/batch/route.ts": TAIL,
|
||||
"lib/measurements/batch-ingest/write-measurements.ts": TAIL_VIA(
|
||||
"lib/measurements/batch-ingest/tail.ts",
|
||||
),
|
||||
"app/api/measurements/route.ts": TAIL,
|
||||
"app/api/mental-health/assessments/route.ts": TAIL,
|
||||
"lib/insights/score-row.ts": TAIL,
|
||||
@@ -157,8 +166,14 @@ describe("measurement write tail", () => {
|
||||
|
||||
it("the files that claim the shared tail call it", () => {
|
||||
const missing = Object.entries(WRITE_SITES)
|
||||
.filter(([, reason]) => reason === TAIL)
|
||||
.map(([file]) => file)
|
||||
.filter(
|
||||
([, reason]) => reason === TAIL || reason.startsWith(TAIL_VIA_PREFIX),
|
||||
)
|
||||
.map(([file, reason]) =>
|
||||
reason.startsWith(TAIL_VIA_PREFIX)
|
||||
? reason.slice(TAIL_VIA_PREFIX.length)
|
||||
: file,
|
||||
)
|
||||
.filter(
|
||||
(file) =>
|
||||
!/afterMeasurementMutation\s*\(/.test(
|
||||
|
||||
Reference in new issue
Block a user