mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
fix(backup): carry the intraday day shape through export and restore
`IntradayCumulativeProfile` shipped classified as backed up and carried by nothing. The drain folds the per-sample rows into twenty-four running totals and deletes the originals in the same transaction, so past the grace window the table is the only copy of a day's curve: a restore rebuilt the account without it, reported success, and left the same-time baseline reading "still learning" for a fortnight. Builder and restore sit together in one file, the way the profile and cycle pairs do, because the restore route delegates and a grep of the route alone lies. The section spreads into the payload rather than being copied key by key. Two curves for the same metric and day, or a curve that is not a whole day, throw naming the day instead of writing a row every reader drops. The pair is proven end to end rather than at each end: the round-trip suite builds a real payload from a populated account, serialises it, parses it back through the real parser, drives the real restore, and asserts on the rows written. Remove the read and it goes red.
This commit is contained in:
1 parent
9d1f21408f
commit
51dbfefbcb
13 files changed
+346
-3
No files matched your search
@@ -85,6 +85,16 @@ import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
|
||||
const OWNER = "user-A";
|
||||
|
||||
/**
|
||||
* A day's cumulative curve, twenty-four running totals. Uneven on purpose: a
|
||||
* flat ramp would survive a restore that rebuilt the shape from `dayTotal`
|
||||
* instead of carrying it, and this file is the only copy of the real one.
|
||||
*/
|
||||
const DAY_CURVE = [
|
||||
0, 0, 0, 0, 0, 0, 120, 940, 2310, 2380, 2400, 2860, 4120, 4180, 4200, 4260,
|
||||
5010, 6340, 7480, 8020, 8090, 8110, 8110, 8110,
|
||||
];
|
||||
|
||||
/* ── the account being backed up ────────────────────────────────────── */
|
||||
|
||||
function sourceClient() {
|
||||
@@ -173,6 +183,24 @@ function sourceClient() {
|
||||
updatedAt: new Date("2026-07-19T00:00:00.000Z"),
|
||||
}),
|
||||
},
|
||||
// The hourly shape of a drained day. The per-sample rows it was folded
|
||||
// from are already gone, so nothing but this row can rebuild it.
|
||||
intradayCumulativeProfile: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "shape-1",
|
||||
userId: OWNER,
|
||||
type: "ACTIVITY_STEPS",
|
||||
dateKey: "2026-07-18",
|
||||
hourlyCumulative: DAY_CURVE,
|
||||
dayTotal: DAY_CURVE[23],
|
||||
sampleCount: 96,
|
||||
timezone: "Europe/Berlin",
|
||||
createdAt: new Date("2026-07-19T02:00:00.000Z"),
|
||||
updatedAt: new Date("2026-07-19T02:00:00.000Z"),
|
||||
},
|
||||
]),
|
||||
},
|
||||
customMetric: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
@@ -349,6 +377,35 @@ describe("backup round trip — export, wire schema, restore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("writes the day's cumulative shape back, every hour of it", async () => {
|
||||
const { written } = await roundTrip();
|
||||
|
||||
const write = written.find(
|
||||
(w) => w.model === "intradayCumulativeProfile" && w.op === "createMany",
|
||||
);
|
||||
expect(
|
||||
write,
|
||||
"the day curve reached the file and nothing wrote it back",
|
||||
).toBeDefined();
|
||||
|
||||
const rows = write!.data as unknown as Array<Record<string, unknown>>;
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
id: "shape-1",
|
||||
userId: OWNER,
|
||||
type: "ACTIVITY_STEPS",
|
||||
dateKey: "2026-07-18",
|
||||
dayTotal: DAY_CURVE[23],
|
||||
sampleCount: 96,
|
||||
// Dropping the zone would make a curve cut on another clock look
|
||||
// comparable to one cut on this account's.
|
||||
timezone: "Europe/Berlin",
|
||||
});
|
||||
// The curve is the part that cannot be rebuilt from anything else, so it
|
||||
// is asserted hour by hour rather than by its total.
|
||||
expect(rows[0].hourlyCumulative).toEqual(DAY_CURVE);
|
||||
});
|
||||
|
||||
it("writes the account's own cycle symptom back before resolving its links", async () => {
|
||||
const { written, res } = await roundTrip();
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
|
||||
import { restoreCycleData } from "@/lib/cycle/backup";
|
||||
import { restoreProfileData } from "@/lib/export/profile-backup";
|
||||
import { restoreIntradayProfileData } from "@/lib/export/intraday-profile-backup";
|
||||
import { invalidateUserData } from "@/lib/cache/invalidate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -67,6 +68,7 @@ interface RestoreResponse {
|
||||
documents: number;
|
||||
healthProfile: number;
|
||||
customMetrics: number;
|
||||
intradayProfiles: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -753,6 +755,15 @@ const handler = apiHandler(
|
||||
// find the cycle restore one line above.
|
||||
const profileCleared = await restoreProfileData(tx, ownerId, payload);
|
||||
|
||||
// The hourly shape of a cumulative day. Both ends live in
|
||||
// `src/lib/export/intraday-profile-backup.ts` beside its builder, so
|
||||
// a grep of THIS file alone will not find them either.
|
||||
const intradayCleared = await restoreIntradayProfileData(
|
||||
tx,
|
||||
ownerId,
|
||||
payload,
|
||||
);
|
||||
|
||||
const biomarkerByName = new Map<string, string>();
|
||||
const restoredBiomarkerIds = new Set<string>();
|
||||
for (const biomarker of payload.biomarkers) {
|
||||
@@ -1110,6 +1121,7 @@ const handler = apiHandler(
|
||||
documents: documents.count,
|
||||
healthProfile: profileCleared.healthProfile,
|
||||
customMetrics: profileCleared.customMetrics,
|
||||
intradayProfiles: intradayCleared.intradayProfiles,
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
@@ -22,6 +22,9 @@ vi.mock("@/lib/db", () => ({
|
||||
// `buildProfileBackupSection`.
|
||||
userHealthProfile: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
customMetric: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// The hourly shape of a cumulative day, read by
|
||||
// `buildIntradayProfileBackupSection`.
|
||||
intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// v1.15.0 — cycle tables read by the full-backup helper.
|
||||
cycleProfile: { findUnique: vi.fn() },
|
||||
menstrualCycle: { findMany: vi.fn() },
|
||||
@@ -84,6 +87,9 @@ beforeEach(() => {
|
||||
null as never,
|
||||
);
|
||||
vi.mocked(prisma.customMetric.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.intradayCumulativeProfile.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -20,6 +20,9 @@ vi.mock("@/lib/db", () => ({
|
||||
// `buildProfileBackupSection`.
|
||||
userHealthProfile: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
customMetric: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// The hourly shape of a cumulative day, read by
|
||||
// `buildIntradayProfileBackupSection`.
|
||||
intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
|
||||
@@ -120,6 +120,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
export_health_profile_count: counts.healthProfile,
|
||||
export_custom_metric_count: counts.customMetrics,
|
||||
export_custom_metric_entry_count: counts.customMetricEntries,
|
||||
export_intraday_profile_count: counts.intradayProfiles,
|
||||
export_archive_bytes: archive.byteLength,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -79,6 +79,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
export_health_profile_count: counts.healthProfile,
|
||||
export_custom_metric_count: counts.customMetrics,
|
||||
export_custom_metric_entry_count: counts.customMetricEntries,
|
||||
export_intraday_profile_count: counts.intradayProfiles,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -246,6 +246,7 @@ export function BackupsSection() {
|
||||
healthProfile?: number;
|
||||
customMetrics?: number;
|
||||
customMetricEntries?: number;
|
||||
intradayProfiles?: number;
|
||||
};
|
||||
}>("/api/admin/backups/upload", { method: "POST", body: fd });
|
||||
},
|
||||
@@ -272,7 +273,8 @@ export function BackupsSection() {
|
||||
(data.summary.nutrientDays ?? 0) +
|
||||
(data.summary.healthProfile ?? 0) +
|
||||
(data.summary.customMetrics ?? 0) +
|
||||
(data.summary.customMetricEntries ?? 0);
|
||||
(data.summary.customMetricEntries ?? 0) +
|
||||
(data.summary.intradayProfiles ?? 0);
|
||||
toast.success(
|
||||
t("admin.section.backups.uploadSuccess", { count: String(total) }),
|
||||
);
|
||||
|
||||
@@ -187,6 +187,22 @@ function makePrisma() {
|
||||
},
|
||||
]),
|
||||
},
|
||||
intradayCumulativeProfile: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "shape-1",
|
||||
userId: "user-1",
|
||||
type: "ACTIVITY_STEPS",
|
||||
dateKey: "2026-07-18",
|
||||
hourlyCumulative: Array.from({ length: 24 }, (_, h) => h * 300),
|
||||
dayTotal: 6900,
|
||||
sampleCount: 96,
|
||||
timezone: "Europe/Berlin",
|
||||
createdAt: new Date("2026-07-19T02:00:00.000Z"),
|
||||
updatedAt: new Date("2026-07-19T02:00:00.000Z"),
|
||||
},
|
||||
]),
|
||||
},
|
||||
// Left unmocked on purpose: `buildProfileBackupSection` runs for real
|
||||
// against these, so the assertions below exercise the builder rather than
|
||||
// a stand-in that would agree with whatever the payload happened to do.
|
||||
@@ -531,9 +547,11 @@ describe("buildFullBackupPayload — section keys reach the payload", () => {
|
||||
...Object.keys(
|
||||
await mocks.buildRecordsBackupSection.mock.results[0].value,
|
||||
),
|
||||
// The profile section runs for real, so its keys come from the builder.
|
||||
// The profile and intraday sections run for real, so their keys come
|
||||
// from the builders.
|
||||
"healthProfile",
|
||||
"customMetrics",
|
||||
"intradayProfiles",
|
||||
];
|
||||
|
||||
const missing = sectionKeys.filter((key) => !(key in payload));
|
||||
@@ -675,4 +693,33 @@ describe("buildFullBackupPayload — profile and custom metrics", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("carries the day curve hour by hour, with its count", async () => {
|
||||
installSectionMocks();
|
||||
const prisma = makePrisma();
|
||||
|
||||
const { payload, counts } = await buildFullBackupPayload(
|
||||
prisma as never,
|
||||
"user-1",
|
||||
{
|
||||
purpose: "disaster-recovery",
|
||||
exportedAt: new Date("2026-07-20T00:00:00.000Z"),
|
||||
},
|
||||
);
|
||||
|
||||
// Rebuilding the curve from `dayTotal` would produce a plausible ramp that
|
||||
// describes no day that happened, so the whole array is asserted.
|
||||
expect(payload.intradayProfiles).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "shape-1",
|
||||
type: "ACTIVITY_STEPS",
|
||||
dateKey: "2026-07-18",
|
||||
hourlyCumulative: Array.from({ length: 24 }, (_, h) => h * 300),
|
||||
dayTotal: 6900,
|
||||
sampleCount: 96,
|
||||
timezone: "Europe/Berlin",
|
||||
}),
|
||||
]);
|
||||
expect(counts.intradayProfiles).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* The intraday-shape backup pair, and the two ways one row can be wrong.
|
||||
*
|
||||
* The end-to-end proof lives in the restore round-trip suite; this file covers
|
||||
* what that one cannot: the refusals. Both are cases where writing the row
|
||||
* would succeed and the day would then be missing from the feature anyway,
|
||||
* which is the failure mode the whole backup guard exists to make loud.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// The slot-count module reaches for the global Prisma client at load; this
|
||||
// test only wants the constant off it.
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
|
||||
import { PROFILE_HOURS } from "@/lib/measurements/intraday-cumulative-profile";
|
||||
import {
|
||||
buildIntradayProfileBackupSection,
|
||||
countIntradayProfileBackupSection,
|
||||
restoreIntradayProfileData,
|
||||
PROFILE_BACKUP_HOURS,
|
||||
type IntradayProfileBackupEntry,
|
||||
} from "@/lib/export/intraday-profile-backup";
|
||||
|
||||
const CURVE = Array.from({ length: 24 }, (_, h) => h * 100);
|
||||
|
||||
function sourceRow() {
|
||||
return {
|
||||
id: "shape-1",
|
||||
userId: "user-A",
|
||||
type: "ACTIVITY_STEPS",
|
||||
dateKey: "2026-07-18",
|
||||
hourlyCumulative: CURVE,
|
||||
dayTotal: CURVE[23],
|
||||
sampleCount: 96,
|
||||
timezone: "Europe/Berlin",
|
||||
createdAt: new Date("2026-07-19T02:00:00.000Z"),
|
||||
updatedAt: new Date("2026-07-19T02:00:00.000Z"),
|
||||
};
|
||||
}
|
||||
|
||||
function client() {
|
||||
return {
|
||||
intradayCumulativeProfile: {
|
||||
findMany: vi.fn().mockResolvedValue([sourceRow()]),
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
function entry(
|
||||
overrides: Partial<IntradayProfileBackupEntry> = {},
|
||||
): IntradayProfileBackupEntry {
|
||||
return {
|
||||
type: "ACTIVITY_STEPS" as IntradayProfileBackupEntry["type"],
|
||||
dateKey: "2026-07-18",
|
||||
hourlyCumulative: CURVE,
|
||||
dayTotal: CURVE[23],
|
||||
sampleCount: 96,
|
||||
timezone: "Europe/Berlin",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function tx(created: Array<Record<string, unknown>>) {
|
||||
return {
|
||||
intradayCumulativeProfile: {
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 3 }),
|
||||
createMany: vi.fn(async (args: { data: Record<string, unknown>[] }) => {
|
||||
created.push(...args.data);
|
||||
return { count: args.data.length };
|
||||
}),
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
describe("intraday profile backup", () => {
|
||||
it("carries the same slot count the reader indexes by", () => {
|
||||
// Re-declared rather than imported, so it is pinned here instead: a change
|
||||
// to one without the other would put rows in the table that every read
|
||||
// path drops.
|
||||
expect(PROFILE_BACKUP_HOURS).toBe(PROFILE_HOURS);
|
||||
});
|
||||
|
||||
it("gives a disaster-recovery payload the row's own identity", async () => {
|
||||
const section = await buildIntradayProfileBackupSection(
|
||||
client(),
|
||||
"user-A",
|
||||
{
|
||||
purpose: "disaster-recovery",
|
||||
},
|
||||
);
|
||||
|
||||
expect(section.intradayProfiles).toHaveLength(1);
|
||||
expect(section.intradayProfiles[0]).toMatchObject({
|
||||
id: "shape-1",
|
||||
type: "ACTIVITY_STEPS",
|
||||
dateKey: "2026-07-18",
|
||||
hourlyCumulative: CURVE,
|
||||
dayTotal: CURVE[23],
|
||||
sampleCount: 96,
|
||||
timezone: "Europe/Berlin",
|
||||
createdAt: "2026-07-19T02:00:00.000Z",
|
||||
updatedAt: "2026-07-19T02:00:00.000Z",
|
||||
});
|
||||
expect(countIntradayProfileBackupSection(section)).toEqual({
|
||||
intradayProfiles: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the row identity out of a portable export", async () => {
|
||||
const section = await buildIntradayProfileBackupSection(client(), "user-A");
|
||||
|
||||
expect(section.intradayProfiles[0]).not.toHaveProperty("id");
|
||||
expect(section.intradayProfiles[0]).not.toHaveProperty("createdAt");
|
||||
// The shape itself still travels — it is the only copy either way.
|
||||
expect(section.intradayProfiles[0].hourlyCumulative).toEqual(CURVE);
|
||||
});
|
||||
|
||||
it("restores the curve and reports what it cleared", async () => {
|
||||
const created: Array<Record<string, unknown>> = [];
|
||||
const cleared = await restoreIntradayProfileData(tx(created), "user-B", {
|
||||
intradayProfiles: [entry({ id: "shape-1" })],
|
||||
});
|
||||
|
||||
expect(cleared).toEqual({ intradayProfiles: 3 });
|
||||
expect(created).toHaveLength(1);
|
||||
expect(created[0]).toMatchObject({
|
||||
id: "shape-1",
|
||||
userId: "user-B",
|
||||
dateKey: "2026-07-18",
|
||||
hourlyCumulative: CURVE,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses two curves for the same metric and day, naming the day", async () => {
|
||||
const created: Array<Record<string, unknown>> = [];
|
||||
|
||||
await expect(
|
||||
restoreIntradayProfileData(tx(created), "user-B", {
|
||||
intradayProfiles: [entry(), entry({ dayTotal: 1 })],
|
||||
}),
|
||||
).rejects.toThrow(/ACTIVITY_STEPS on 2026-07-18/);
|
||||
expect(
|
||||
created,
|
||||
"nothing may land when the file contradicts itself",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses a curve that is not a whole day, naming the day", async () => {
|
||||
const created: Array<Record<string, unknown>> = [];
|
||||
|
||||
await expect(
|
||||
restoreIntradayProfileData(tx(created), "user-B", {
|
||||
intradayProfiles: [entry({ hourlyCumulative: CURVE.slice(0, 23) })],
|
||||
}),
|
||||
).rejects.toThrow(/23 hourly slots, not 24/);
|
||||
expect(created, "a row every reader drops is worse than none").toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -154,12 +154,14 @@ export const BACKUP_WRITER_FILES: readonly string[] = [
|
||||
"src/lib/export/paged-measurements.ts",
|
||||
"src/lib/export/records-backup.ts",
|
||||
"src/lib/export/profile-backup.ts",
|
||||
"src/lib/export/intraday-profile-backup.ts",
|
||||
"src/lib/cycle/backup.ts",
|
||||
];
|
||||
|
||||
export const BACKUP_RESTORE_FILES: readonly string[] = [
|
||||
"src/app/api/admin/backups/[id]/restore/route.ts",
|
||||
"src/lib/export/profile-backup.ts",
|
||||
"src/lib/export/intraday-profile-backup.ts",
|
||||
"src/lib/cycle/backup.ts",
|
||||
];
|
||||
|
||||
@@ -178,6 +180,7 @@ export const BACKUP_RESTORE_FILES: readonly string[] = [
|
||||
*/
|
||||
export const TWO_ENDED_MODELS: readonly string[] = [
|
||||
"Measurement",
|
||||
"IntradayCumulativeProfile",
|
||||
"Medication",
|
||||
"MedicationSchedule",
|
||||
"MedicationIntakeEvent",
|
||||
|
||||
@@ -31,9 +31,18 @@ import {
|
||||
type ProfileBackupCounts,
|
||||
type ProfileBackupSection,
|
||||
} from "@/lib/export/profile-backup";
|
||||
import {
|
||||
buildIntradayProfileBackupSection,
|
||||
countIntradayProfileBackupSection,
|
||||
type IntradayProfileBackupCounts,
|
||||
type IntradayProfileBackupSection,
|
||||
} from "@/lib/export/intraday-profile-backup";
|
||||
|
||||
export interface FullBackupCounts
|
||||
extends RecordsBackupCounts, ProfileBackupCounts {
|
||||
extends
|
||||
RecordsBackupCounts,
|
||||
ProfileBackupCounts,
|
||||
IntradayProfileBackupCounts {
|
||||
measurements: number;
|
||||
medications: number;
|
||||
intakeEvents: number;
|
||||
@@ -107,6 +116,7 @@ export async function buildFullBackupPayload(
|
||||
cycle,
|
||||
records,
|
||||
profile,
|
||||
intradayProfiles,
|
||||
nutrientDays,
|
||||
] = await Promise.all([
|
||||
disasterRecovery
|
||||
@@ -210,6 +220,12 @@ export async function buildFullBackupPayload(
|
||||
buildProfileBackupSection(prisma, userId, {
|
||||
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
|
||||
}),
|
||||
// The hourly shape of a cumulative day. Not derived, despite looking it:
|
||||
// the drain folds it out of the per-sample rows in the same transaction
|
||||
// that deletes them, so past the grace window nothing can rebuild it.
|
||||
buildIntradayProfileBackupSection(prisma, userId, {
|
||||
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
|
||||
}),
|
||||
// Nutrient day totals were absent from every export path, which
|
||||
// contradicted the schema's own reason for denormalising the unit column
|
||||
// ("rows stay self-describing in exports even if the catalog ever drifts").
|
||||
@@ -234,6 +250,7 @@ export async function buildFullBackupPayload(
|
||||
const cycleSection: CycleBackupSection = cycle;
|
||||
const recordsSection: RecordsBackupSection = records;
|
||||
const profileSection: ProfileBackupSection = profile;
|
||||
const intradaySection: IntradayProfileBackupSection = intradayProfiles;
|
||||
|
||||
const payload = {
|
||||
schemaVersion: BACKUP_SCHEMA_VERSION,
|
||||
@@ -379,6 +396,7 @@ export async function buildFullBackupPayload(
|
||||
...cycleSection,
|
||||
...recordsSection,
|
||||
...profileSection,
|
||||
...intradaySection,
|
||||
nutrientDays: nutrientDays.map((n) => ({
|
||||
day: n.day,
|
||||
nutrient: n.nutrient,
|
||||
@@ -401,6 +419,7 @@ export async function buildFullBackupPayload(
|
||||
nutrientDays: nutrientDays.length,
|
||||
...countRecordsBackupSection(records),
|
||||
...countProfileBackupSection(profile),
|
||||
...countIntradayProfileBackupSection(intradayProfiles),
|
||||
},
|
||||
};
|
||||
}
|
||||
Binary file not shown.
@@ -454,6 +454,34 @@ const customMetricBackupSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/**
|
||||
* One local day's cumulative curve for one metric.
|
||||
*
|
||||
* The drain writes this row and deletes the per-sample rows it was folded from
|
||||
* in the same transaction, so past the grace window the file is the only copy.
|
||||
* `hourlyCumulative` is left unconstrained in length here on purpose: the
|
||||
* restore checks it against the slot count it shares with the reader and
|
||||
* throws naming the day, which says what is wrong with one row instead of
|
||||
* making a whole file unparseable.
|
||||
*/
|
||||
const intradayProfileBackupSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).optional(),
|
||||
type: z.enum(MeasurementType),
|
||||
dateKey: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
hourlyCumulative: z.array(z.number()),
|
||||
dayTotal: z.number(),
|
||||
sampleCount: z.number().int(),
|
||||
// The zone the day was cut on. The reader compares it against the
|
||||
// account's current zone and drops the mismatched days, so a restore that
|
||||
// dropped this column would make old curves look comparable when their
|
||||
// hours mean something else.
|
||||
timezone: z.string().min(1),
|
||||
createdAt: isoDateTime.optional(),
|
||||
updatedAt: isoDateTime.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const appSettingsBackupSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
@@ -721,6 +749,9 @@ export const backupPayloadSchema = z
|
||||
// `null` / `[]`.
|
||||
healthProfile: healthProfileBackupSchema.nullable().default(null),
|
||||
customMetrics: z.array(customMetricBackupSchema).default([]),
|
||||
// The hourly shape of a cumulative day. Defaulted for the same reason as
|
||||
// the pair above: a file written before the table existed carries no key.
|
||||
intradayProfiles: z.array(intradayProfileBackupSchema).default([]),
|
||||
manifest: backupManifestSchema.nullable().default(null),
|
||||
})
|
||||
.passthrough()
|
||||
@@ -779,6 +810,8 @@ export interface BackupSummary {
|
||||
customMetrics: number;
|
||||
/** Readings across every user-defined metric. */
|
||||
customMetricEntries: number;
|
||||
/** Stored day curves for the cumulative metrics, across every metric. */
|
||||
intradayProfiles: number;
|
||||
}
|
||||
|
||||
export function summarizeBackup(payload: BackupPayload): BackupSummary {
|
||||
@@ -810,6 +843,7 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary {
|
||||
(sum, metric) => sum + metric.entries.length,
|
||||
0,
|
||||
),
|
||||
intradayProfiles: payload.intradayProfiles.length,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user