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

@@ -0,0 +1,15 @@
-- v1.15.13 — source-filter support indexes for the measurements + mood
-- management lists. The new list surfaces add a source filter (and the
-- existing source/value sort). No index leads with `(user_id, source)`
-- today, so a source filter forces a `user_id` scan + in-memory sort —
-- the landmine at 100–200k rows per user. These two additive indexes turn
-- a source filter + recency sort into an index range scan.
--
-- Pure additive `CREATE INDEX IF NOT EXISTS`, no backfill — same low-risk
-- shape as migration 0123.
CREATE INDEX IF NOT EXISTS "measurements_user_id_source_measured_at_idx"
ON "measurements" ("user_id", "source", "measured_at" DESC);
CREATE INDEX IF NOT EXISTS "mood_entries_user_id_source_mood_logged_at_idx"
ON "mood_entries" ("user_id", "source", "mood_logged_at" DESC);
+8
View File
@@ -850,6 +850,10 @@ model Measurement {
// (Postgres semantics) so manual entries don't collide here.
@@unique([userId, type, source, externalId])
@@index([userId, type, measuredAt])
// v1.15.13 — leads with `(userId, source)` so the management list's
// source filter + recency sort is an index range scan instead of a
// `userId` scan + in-memory sort. See migration 0136.
@@index([userId, source, measuredAt])
@@index([externalId])
// v1.7.0 — supports the `/api/sync/changes` keyset feed, which walks
// `where userId` ordered by `(updatedAt asc, id asc)` with `take
@@ -2082,6 +2086,10 @@ model MoodEntry {
// null, moodLoggedAt: { gte } }` ordered by `moodLoggedAt desc`; this
// index turns that into a range scan. See migration 0123.
@@index([userId, moodLoggedAt])
// v1.15.13 — leads with `(userId, source)` so the mood management
// list's source filter + recency sort is an index range scan. See
// migration 0136.
@@index([userId, source, moodLoggedAt])
// v1.7.0 sync — keyset support for the `/api/sync/changes` mood feed
// (`where userId` ordered by `(updatedAt asc, id asc)`).
@@index([userId, updatedAt, id])
+5
View File
@@ -97,6 +97,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
sortDir,
aggregate,
source,
sourceEq,
groupBy,
dayKey,
} = parsed.data;
@@ -110,6 +111,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
// dashboard read paths) must never include them.
deletedAt: null,
...(type && { type: type as MeasurementType }),
// v1.15.13 — management-list source filter. Distinct from the
// `source=rollup` opt-in above (which selects the rollup read tier);
// `sourceEq` narrows the plain list to one ingest source.
...(sourceEq && { source: sourceEq }),
...(from || to
? {
measuredAt: {
@@ -178,6 +178,38 @@ describe("GET /api/mood-entries — 422 multi-issue (v1.4.43 W6)", () => {
});
});
describe("GET /api/mood-entries — source filter (v1.15.13)", () => {
it("threads a valid source into the list where", async () => {
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.moodEntry.count).mockResolvedValue(0 as never);
const res = await GET(getReq("source=TELEGRAM"));
expect(res.status).toBe(200);
const call = vi.mocked(prisma.moodEntry.findMany).mock.calls[0][0];
expect(call?.where).toMatchObject({
userId: "user-1",
deletedAt: null,
source: "TELEGRAM",
});
});
it("omits source from the where when not supplied", async () => {
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.moodEntry.count).mockResolvedValue(0 as never);
const res = await GET(getReq(""));
expect(res.status).toBe(200);
const call = vi.mocked(prisma.moodEntry.findMany).mock.calls[0][0];
expect(call?.where).not.toHaveProperty("source");
});
it("422s on an unknown source value", async () => {
const res = await GET(getReq("source=BOGUS"));
expect(res.status).toBe(422);
});
});
describe("POST /api/mood-entries — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// Bad `mood` enum + missing `moodLoggedAt`.
+4 -1
View File
@@ -71,13 +71,16 @@ export const GET = apiHandler(async (request: NextRequest) => {
return returnAllZodIssues(parsed.error, 422);
}
const { mood, from, to, limit, offset, sortBy, sortDir } = parsed.data;
const { mood, source, from, to, limit, offset, sortBy, sortDir } =
parsed.data;
const where = {
userId: user.id,
// v1.7.0 sync — hide soft-deleted (tombstoned) rows from the list.
deletedAt: null,
...(mood && { mood }),
// v1.15.13 — management-list source filter.
...(source && { source }),
...(from || to
? {
date: {
@@ -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}$/)