feat(api): add source filter to measurement and mood lists

Add a source filter to both management lists. Mood gains an optional
`source` param (validated against the mood source set); measurements gain
`sourceEq` (validated against MeasurementSource) — the `source` key on the
measurements list is the rollup-tier opt-in, so the filter rides a distinct
param. Both thread into the list `where` alongside the pinned `userId` +
`deletedAt: null`.

Migration 0136 adds two additive indexes — `(userId, source, measuredAt)`
on measurements and `(userId, source, moodLoggedAt)` on mood — so a source
filter + recency sort is an index range scan rather than a userId scan plus
in-memory sort at 100-200k rows per user. Pure `CREATE INDEX IF NOT EXISTS`,
no backfill.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-07 19:23:59 +02:00
1 parent 1fd875b83c
commit 0d69611bbf
8 files changed
+117 -1

No files matched your search

@@ -236,6 +236,48 @@ describe("measurement validation", () => {
});
});
describe("listMeasurementsSchema — v1.15.13 sourceEq filter", () => {
it("accepts a valid MeasurementSource on sourceEq", () => {
const parsed = listMeasurementsSchema.safeParse({ sourceEq: "WITHINGS" });
expect(parsed.success).toBe(true);
if (parsed.success) {
expect(parsed.data.sourceEq).toBe("WITHINGS");
}
});
it("leaves sourceEq undefined when omitted", () => {
const parsed = listMeasurementsSchema.safeParse({});
expect(parsed.success).toBe(true);
if (parsed.success) {
expect(parsed.data.sourceEq).toBeUndefined();
}
});
it("rejects an unknown sourceEq value", () => {
const parsed = listMeasurementsSchema.safeParse({ sourceEq: "BOGUS" });
expect(parsed.success).toBe(false);
});
it("rejects the rollup opt-in value on sourceEq (distinct from source)", () => {
// `rollup` is the rollup-tier opt-in for `source`, NOT a valid
// MeasurementSource filter — sourceEq must not accept it.
const parsed = listMeasurementsSchema.safeParse({ sourceEq: "rollup" });
expect(parsed.success).toBe(false);
});
it("accepts source=rollup and sourceEq together (independent params)", () => {
const parsed = listMeasurementsSchema.safeParse({
source: "rollup",
sourceEq: "APPLE_HEALTH",
});
expect(parsed.success).toBe(true);
if (parsed.success) {
expect(parsed.data.source).toBe("rollup");
expect(parsed.data.sourceEq).toBe("APPLE_HEALTH");
}
});
});
// v1.4.38 — the per-day drill-down branch always returns at most
// 1000 rows (one pathological phone-only stepCount day). Push the
// cap into the validator so a caller asking for more sees a 422
+6
View File
@@ -586,6 +586,12 @@ export const listMeasurementsSchema = z
// when the rollup bucket set is empty for the requested window so
// brand-new accounts still see correct data on their first chart.
source: z.enum(["rollup"]).optional(),
// v1.15.13 — management-list source FILTER. The `source` key above is
// the rollup-tier opt-in (`["rollup"]`), NOT a source filter, so the
// filter rides a distinct param. Validated against `MeasurementSource`;
// threaded into the plain-list `where`. Backed by the `(userId, source,
// measuredAt)` index (migration 0136).
sourceEq: measurementSourceEnum.optional(),
// v1.4.37 W7c — list-view "one row per day" mode for cumulative
// types (steps, active energy, distance, flights, daylight). When
// `groupBy=day` is set and `type` is a cumulative HK type, the route
+5
View File
@@ -165,6 +165,11 @@ export const updateMoodEntrySchema = z.object({
export const listMoodEntriesSchema = z.object({
mood: moodLevelEnum.optional(),
// v1.15.13 — source filter for the management list. Validated against
// the mood source set; threaded into the list `where` (the `userId` +
// `deletedAt: null` pins stay). Backed by the `(userId, source,
// moodLoggedAt)` index (migration 0136).
source: moodSourceEnum.optional(),
from: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)