diff --git a/prisma/migrations/0136_v11513_source_filter_indexes/migration.sql b/prisma/migrations/0136_v11513_source_filter_indexes/migration.sql new file mode 100644 index 000000000..e2dab1bb0 --- /dev/null +++ b/prisma/migrations/0136_v11513_source_filter_indexes/migration.sql @@ -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); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index db2fb2cb7..3c6d22dd4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 91d88109d..ac55d66a2 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -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: { diff --git a/src/app/api/mood-entries/__tests__/route.test.ts b/src/app/api/mood-entries/__tests__/route.test.ts index b501ed812..4e0a06e1b 100644 --- a/src/app/api/mood-entries/__tests__/route.test.ts +++ b/src/app/api/mood-entries/__tests__/route.test.ts @@ -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`. diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index 4da453a97..e24894ab6 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -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: { diff --git a/src/lib/validations/__tests__/measurement.test.ts b/src/lib/validations/__tests__/measurement.test.ts index 3b95a933e..403fcf3b2 100644 --- a/src/lib/validations/__tests__/measurement.test.ts +++ b/src/lib/validations/__tests__/measurement.test.ts @@ -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 diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 0b843fc5b..c2de3706f 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -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 diff --git a/src/lib/validations/moodlog.ts b/src/lib/validations/moodlog.ts index 6597a3358..d093101f3 100644 --- a/src/lib/validations/moodlog.ts +++ b/src/lib/validations/moodlog.ts @@ -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}$/)