mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
501 lines
16 KiB
TypeScript
501 lines
16 KiB
TypeScript
/**
|
|
* The batched writer behind `POST /api/import/csv`.
|
|
*
|
|
* The route reads, parses and bounds the file; this module writes its
|
|
* accepted rows. It pre-fetches the existing rows under the IMPORT source in
|
|
* one query per dedup-key shape (per type and chunk), partitions the file
|
|
* into insert and update sets, then writes them with `createManyAndReturn`
|
|
* and grouped `updateMany` in bounded chunks. The per-line
|
|
* inserted / updated / duplicate envelope is reconstructed from the
|
|
* partition. Serial per-row upserts once approached the request timeout on a
|
|
* few-thousand-row file; this is the shape `/measurements/batch` uses.
|
|
*/
|
|
import { prisma } from "@/lib/db";
|
|
import { annotate } from "@/lib/logging/context";
|
|
import { auditLog } from "@/lib/auth/audit";
|
|
import { encryptNote, readNote } from "@/lib/crypto/note-cipher";
|
|
import type { CsvRowResult } from "@/lib/import/csv-measurements";
|
|
import type { NormalisedMeasurementRow } from "@/lib/import/csv-measurements";
|
|
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
|
|
import type {
|
|
GlucoseContext,
|
|
MeasurementType,
|
|
Prisma,
|
|
} from "@/generated/prisma/client";
|
|
import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit";
|
|
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
|
|
|
|
/** Keys per dedup probe: well under Postgres's bind-parameter cap. */
|
|
const LOOKUP_CHUNK = 1000;
|
|
/** Rows per create call / per update batch transaction. */
|
|
const WRITE_CHUNK = 200;
|
|
|
|
/** A parse-`ok` line with its normalised row. */
|
|
export type CsvOkRow = CsvRowResult & {
|
|
row: NonNullable<CsvRowResult["row"]>;
|
|
};
|
|
|
|
type WriteOutcome = "inserted" | "updated" | "duplicate";
|
|
|
|
interface StoredImportRow {
|
|
value: number;
|
|
unit: string;
|
|
measuredAt: Date;
|
|
glucoseContext: GlucoseContext | null;
|
|
notes: string | null;
|
|
notesEncrypted: Uint8Array | null;
|
|
deletedAt: Date | null;
|
|
}
|
|
|
|
/** The counts and per-line outcomes of one import, filled as it plans. */
|
|
interface CsvTally {
|
|
inserted: number;
|
|
updated: number;
|
|
skipped: number;
|
|
/**
|
|
* Per-line write outcome for rows that reached the DB. Keyed by source
|
|
* line so the response envelope can surface `inserted` / `updated` /
|
|
* `duplicate` distinctly without polluting the parse-time `CsvRowReason`.
|
|
*/
|
|
outcome: Map<number, WriteOutcome>;
|
|
touched: Array<{ type: MeasurementType; measuredAt: Date }>;
|
|
}
|
|
|
|
function chunked<T>(items: readonly T[], size: number): T[][] {
|
|
const out: T[][] = [];
|
|
for (let i = 0; i < items.length; i += size) {
|
|
out.push(items.slice(i, i + size));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Group rows' probe keys by measurement type, de-duplicated. */
|
|
function groupByType<K>(
|
|
rows: ReadonlyArray<{ row: NormalisedMeasurementRow }>,
|
|
keyOf: (r: { row: NormalisedMeasurementRow }) => K,
|
|
): Map<MeasurementType, K[]> {
|
|
const byType = new Map<MeasurementType, Map<string, K>>();
|
|
for (const r of rows) {
|
|
const type = r.row.type as MeasurementType;
|
|
const key = keyOf(r);
|
|
const bucket = byType.get(type) ?? new Map<string, K>();
|
|
bucket.set(key instanceof Date ? String(key.getTime()) : String(key), key);
|
|
byType.set(type, bucket);
|
|
}
|
|
return new Map([...byType].map(([type, keys]) => [type, [...keys.values()]]));
|
|
}
|
|
|
|
/**
|
|
* True when a stored IMPORT row already holds exactly what this line would
|
|
* write: same value, unit, instant, glucose context and note, and live. Such
|
|
* a line is a duplicate, not an update.
|
|
*/
|
|
function sameStoredRow(
|
|
stored: StoredImportRow,
|
|
m: NormalisedMeasurementRow,
|
|
): boolean {
|
|
return (
|
|
stored.deletedAt === null &&
|
|
stored.value === m.value &&
|
|
stored.unit === m.unit &&
|
|
stored.measuredAt.getTime() === m.measuredAt.getTime() &&
|
|
stored.glucoseContext ===
|
|
((m.glucoseContext as GlucoseContext | undefined) ?? null) &&
|
|
readNote(stored.notesEncrypted, stored.notes) === (m.notes || null)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Rows with an external id: idempotent upsert on
|
|
* `(userId, type, source=IMPORT, externalId)`. One probe per type and chunk
|
|
* fetches every pre-existing key with the columns an update would write, so
|
|
* a row whose stored values already match is left alone.
|
|
*
|
|
* Partitioned in file order, honouring in-file repeats: the first sighting
|
|
* of a brand-new key inserts, every later sighting (or any sighting of a key
|
|
* already in the DB) updates in place. The last value for a key wins,
|
|
* matching the old sequential-upsert overwrite. Insert / update maps are
|
|
* keyed by (type, externalId) so a repeated key collapses to one write. A
|
|
* key already in the DB whose stored row equals this line is a duplicate: no
|
|
* write, reported like the natural-key duplicates. That is what makes
|
|
* re-importing the same file cheap: a second upload of an unchanged
|
|
* 9 000-row export writes nothing.
|
|
*/
|
|
async function planExternalIdRows(
|
|
userId: string,
|
|
extRows: CsvOkRow[],
|
|
tally: CsvTally,
|
|
): Promise<{
|
|
inserts: Map<string, NormalisedMeasurementRow>;
|
|
updates: Map<string, NormalisedMeasurementRow>;
|
|
}> {
|
|
const existing = new Map<string, StoredImportRow>();
|
|
for (const [type, ids] of groupByType(
|
|
extRows,
|
|
(r) => r.row.externalId as string,
|
|
)) {
|
|
for (const chunk of chunked(ids, LOOKUP_CHUNK)) {
|
|
const found = await prisma.measurement.findMany({
|
|
where: {
|
|
userId,
|
|
source: "IMPORT",
|
|
type,
|
|
externalId: { in: chunk },
|
|
},
|
|
select: {
|
|
externalId: true,
|
|
value: true,
|
|
unit: true,
|
|
measuredAt: true,
|
|
glucoseContext: true,
|
|
notes: true,
|
|
notesEncrypted: true,
|
|
deletedAt: true,
|
|
},
|
|
});
|
|
for (const row of found) {
|
|
existing.set(`${type}::${row.externalId}`, row);
|
|
}
|
|
}
|
|
}
|
|
|
|
const seen = new Set<string>();
|
|
const inserts = new Map<string, NormalisedMeasurementRow>();
|
|
const updates = new Map<string, NormalisedMeasurementRow>();
|
|
for (const result of extRows) {
|
|
const m = result.row;
|
|
const key = `${m.type}::${m.externalId}`;
|
|
const stored = existing.get(key);
|
|
if (stored && !updates.has(key) && sameStoredRow(stored, m)) {
|
|
tally.skipped++;
|
|
tally.outcome.set(result.line, "duplicate");
|
|
continue;
|
|
}
|
|
if (stored) {
|
|
updates.set(key, m);
|
|
tally.updated++;
|
|
tally.outcome.set(result.line, "updated");
|
|
} else if (seen.has(key)) {
|
|
// New-to-DB key seen again in this file: still a single create with
|
|
// the latest value, but this line reads as an update just as the
|
|
// sequential upsert did.
|
|
inserts.set(key, m);
|
|
tally.updated++;
|
|
tally.outcome.set(result.line, "updated");
|
|
} else {
|
|
seen.add(key);
|
|
inserts.set(key, m);
|
|
tally.inserted++;
|
|
tally.outcome.set(result.line, "inserted");
|
|
}
|
|
tally.touched.push({
|
|
type: m.type as MeasurementType,
|
|
measuredAt: m.measuredAt,
|
|
});
|
|
// An update that moves the reading to another instant leaves the day it
|
|
// came from: that day is re-folded too, or the old day keeps a reading
|
|
// that no longer exists there.
|
|
if (stored && stored.measuredAt.getTime() !== m.measuredAt.getTime()) {
|
|
tally.touched.push({
|
|
type: m.type as MeasurementType,
|
|
measuredAt: stored.measuredAt,
|
|
});
|
|
}
|
|
}
|
|
return { inserts, updates };
|
|
}
|
|
|
|
/**
|
|
* Rows without an external id: create-only, deduped on the natural unique
|
|
* key (userId, type, measuredAt, source=IMPORT, sleepStage=null). A
|
|
* collision, against the DB or an earlier row in this same file, is a
|
|
* duplicate, never an overwrite (each sample is a canonical reading).
|
|
* Probed per type and chunk, like the external-id rows.
|
|
*/
|
|
async function planPlainRows(
|
|
userId: string,
|
|
plainRows: CsvOkRow[],
|
|
tally: CsvTally,
|
|
): Promise<NormalisedMeasurementRow[]> {
|
|
const existing = new Set<string>();
|
|
for (const [type, instants] of groupByType(
|
|
plainRows,
|
|
(r) => r.row.measuredAt,
|
|
)) {
|
|
for (const chunk of chunked(instants, LOOKUP_CHUNK)) {
|
|
const found = await prisma.measurement.findMany({
|
|
where: {
|
|
userId,
|
|
source: "IMPORT",
|
|
type,
|
|
measuredAt: { in: chunk },
|
|
},
|
|
select: { measuredAt: true },
|
|
});
|
|
for (const row of found) {
|
|
existing.add(`${type}::${row.measuredAt.getTime()}`);
|
|
}
|
|
}
|
|
}
|
|
const seen = new Set<string>();
|
|
const inserts: NormalisedMeasurementRow[] = [];
|
|
for (const result of plainRows) {
|
|
const m = result.row;
|
|
const key = `${m.type}::${m.measuredAt.getTime()}`;
|
|
if (existing.has(key) || seen.has(key)) {
|
|
tally.skipped++;
|
|
tally.outcome.set(result.line, "duplicate");
|
|
} else {
|
|
seen.add(key);
|
|
inserts.push(m);
|
|
tally.inserted++;
|
|
tally.outcome.set(result.line, "inserted");
|
|
tally.touched.push({
|
|
type: m.type as MeasurementType,
|
|
measuredAt: m.measuredAt,
|
|
});
|
|
}
|
|
}
|
|
return inserts;
|
|
}
|
|
|
|
/**
|
|
* Under `skipDuplicates` a conflicting row is silently absorbed: the key is
|
|
* present in the table either way, so the SPECIFIC raced lines cannot be
|
|
* identified; only the counters and per-line statuses have to sum to the
|
|
* truth. Downgrade enough `inserted` lines to `duplicate` so the envelope
|
|
* matches the DB write count (the batch route's raced-duplicate downgrade).
|
|
*/
|
|
function downgradeRacedInserts(tally: CsvTally, raced: number): void {
|
|
if (raced <= 0) return;
|
|
let downgraded = 0;
|
|
for (const [line, outcome] of tally.outcome) {
|
|
if (downgraded >= raced) break;
|
|
if (outcome === "inserted") {
|
|
tally.outcome.set(line, "duplicate");
|
|
tally.inserted--;
|
|
tally.skipped++;
|
|
downgraded++;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Bulk write in bounded chunks, each its own short transaction.
|
|
*
|
|
* This used to be ONE interactive transaction around every create chunk AND
|
|
* one `updateMany` per re-imported row. Re-importing a 9 000-row file with an
|
|
* externalId column meant 9 000 serial updates inside it, which outlived
|
|
* Prisma's 5 s interactive-transaction timeout and ended in a 500. A chunk
|
|
* now commits on its own: creates go through `createManyAndReturn`
|
|
* (skipDuplicates absorbs a concurrent double-submit), updates through one
|
|
* batch transaction per chunk. A failure part-way leaves the earlier chunks
|
|
* written, and that is safe to retry: a re-upload of the same file skips
|
|
* what landed (unchanged externalId rows and natural-key duplicates are not
|
|
* written again).
|
|
*/
|
|
async function writeRows(
|
|
userId: string,
|
|
toCreate: Prisma.MeasurementCreateManyInput[],
|
|
updates: NormalisedMeasurementRow[],
|
|
tally: CsvTally,
|
|
): Promise<void> {
|
|
let createdCount = 0;
|
|
const createdRows: Array<{
|
|
id: string;
|
|
type: MeasurementType;
|
|
measuredAt: Date;
|
|
}> = [];
|
|
for (const chunk of chunked(toCreate, WRITE_CHUNK)) {
|
|
const rows = await prisma.measurement.createManyAndReturn({
|
|
data: chunk,
|
|
skipDuplicates: true,
|
|
select: { id: true, type: true, measuredAt: true },
|
|
});
|
|
createdRows.push(...rows);
|
|
createdCount += rows.length;
|
|
}
|
|
for (const chunk of chunked(updates, WRITE_CHUNK)) {
|
|
await prisma.$transaction(
|
|
chunk.map((m) =>
|
|
prisma.measurement.updateMany({
|
|
where: {
|
|
userId,
|
|
source: "IMPORT",
|
|
type: m.type as MeasurementType,
|
|
externalId: m.externalId as string,
|
|
},
|
|
data: {
|
|
value: m.value,
|
|
unit: m.unit,
|
|
measuredAt: m.measuredAt,
|
|
notes: null,
|
|
notesEncrypted: encryptNote(m.notes ?? null),
|
|
glucoseContext:
|
|
(m.glucoseContext as GlucoseContext | undefined) ?? null,
|
|
// No-op on a live row, a deliberate RESURRECTION on a tombstoned
|
|
// one: IMPORT rows are re-importable by design, so a re-imported
|
|
// externalId brings the row back (mirrors the source-owned sync
|
|
// resurrect rule).
|
|
deletedAt: null,
|
|
},
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
void emitInsertedMeasurementArrivals(userId, createdRows, "csv_import").catch(
|
|
() => {},
|
|
);
|
|
void maybeEnqueueMorningRefresh(
|
|
userId,
|
|
createdRows
|
|
.filter((row) => row.type === "SLEEP_DURATION")
|
|
.map((row) => row.measuredAt),
|
|
).catch(() => {});
|
|
|
|
downgradeRacedInserts(tally, toCreate.length - createdCount);
|
|
}
|
|
|
|
async function importRows(
|
|
userId: string,
|
|
okRows: CsvOkRow[],
|
|
tally: CsvTally,
|
|
ipAddress: string | null,
|
|
): Promise<void> {
|
|
const buildCreateData = (
|
|
m: NormalisedMeasurementRow,
|
|
): Prisma.MeasurementCreateManyInput => ({
|
|
userId,
|
|
type: m.type as MeasurementType,
|
|
value: m.value,
|
|
unit: m.unit,
|
|
source: "IMPORT",
|
|
...(m.externalId ? { externalId: m.externalId } : {}),
|
|
measuredAt: m.measuredAt,
|
|
notes: null,
|
|
notesEncrypted: encryptNote(m.notes ?? null),
|
|
glucoseContext: (m.glucoseContext as GlucoseContext | undefined) ?? null,
|
|
});
|
|
|
|
const ext = await planExternalIdRows(
|
|
userId,
|
|
okRows.filter((r) => r.row.externalId),
|
|
tally,
|
|
);
|
|
const plainInserts = await planPlainRows(
|
|
userId,
|
|
okRows.filter((r) => !r.row.externalId),
|
|
tally,
|
|
);
|
|
|
|
const toCreate: Prisma.MeasurementCreateManyInput[] = [
|
|
...[...ext.inserts.values()].map(buildCreateData),
|
|
...plainInserts.map(buildCreateData),
|
|
];
|
|
// The re-fold runs whatever happens to the writes. A chunk that fails
|
|
// part-way leaves the earlier chunks written, and a retry reads those rows
|
|
// as duplicates and touches nothing: without the re-fold here their days
|
|
// would keep the rollups from before the import for good. Re-folding a
|
|
// (type, day) nothing landed on is harmless.
|
|
try {
|
|
await writeRows(userId, toCreate, [...ext.updates.values()], tally);
|
|
} finally {
|
|
// One bounded rollup re-fold per touched (type, day): a 10 000-row CSV
|
|
// pays at most ~N (type, day) recomputes, not 10 000 per-row hooks, and
|
|
// the status re-warm for the touched types. Best-effort: a populator
|
|
// hiccup never fails the importer.
|
|
await afterMeasurementMutation(userId, tally.touched, {
|
|
source: "manual",
|
|
label: "import.csv",
|
|
});
|
|
}
|
|
|
|
await auditLog("import.csv.upload", {
|
|
userId,
|
|
ipAddress,
|
|
details: {
|
|
inserted: tally.inserted,
|
|
updated: tally.updated,
|
|
skipped: tally.skipped,
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Write (or, with `dryRun`, only project) one parsed CSV file and answer the
|
|
* route's envelope: `{ inserted, updated, skipped, total, dryRun, rows }`.
|
|
*/
|
|
export async function runCsvImport(input: {
|
|
userId: string;
|
|
rows: CsvRowResult[];
|
|
okRows: CsvOkRow[];
|
|
dryRun: boolean;
|
|
ipAddress: string | null;
|
|
}) {
|
|
const { rows, okRows, dryRun } = input;
|
|
const tally: CsvTally = {
|
|
inserted: 0,
|
|
updated: 0,
|
|
skipped: rows.filter((r) => r.status === "skipped").length,
|
|
outcome: new Map(),
|
|
touched: [],
|
|
};
|
|
|
|
if (!dryRun) {
|
|
await importRows(input.userId, okRows, tally, input.ipAddress);
|
|
} else {
|
|
// Dry-run preview: no write ran, so report the projected outcome: every
|
|
// valid parse-`ok` row would insert (an externalId row could land as an
|
|
// update, but that needs a DB read deliberately skipped here).
|
|
tally.inserted = okRows.length;
|
|
}
|
|
|
|
const summary = {
|
|
inserted: tally.inserted,
|
|
updated: tally.updated,
|
|
skipped: tally.skipped,
|
|
total: rows.length,
|
|
dryRun,
|
|
// Per-row status, mirroring the batch route's per-entry envelope. The
|
|
// normalised payload is stripped: the client only needs line + status.
|
|
// Parse-time `ok` rows are refined by the write outcome: a row that hit
|
|
// a unique-constraint duplicate becomes `skipped`/`duplicate`; an upsert
|
|
// that landed on an existing row becomes `updated`. In `dryRun` mode no
|
|
// write ran, so every parse-`ok` row stays `inserted` (the projected
|
|
// outcome).
|
|
rows: rows.map((r) => {
|
|
if (r.status === "skipped") {
|
|
return {
|
|
line: r.line,
|
|
status: "skipped" as const,
|
|
...(r.reason ? { reason: r.reason } : {}),
|
|
};
|
|
}
|
|
const outcome = tally.outcome.get(r.line);
|
|
if (outcome === "duplicate") {
|
|
return {
|
|
line: r.line,
|
|
status: "skipped" as const,
|
|
reason: "duplicate",
|
|
};
|
|
}
|
|
if (outcome === "updated") {
|
|
return { line: r.line, status: "updated" as const };
|
|
}
|
|
return { line: r.line, status: "inserted" as const };
|
|
}),
|
|
};
|
|
|
|
annotate({
|
|
meta: {
|
|
import_csv_inserted: tally.inserted,
|
|
import_csv_updated: tally.updated,
|
|
import_csv_skipped: tally.skipped,
|
|
import_csv_dry_run: dryRun,
|
|
},
|
|
});
|
|
|
|
return summary;
|
|
}
|