mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Merge remote-tracking branch 'origin/release/v1.46.0' into fix/ai-chain-review
This commit is contained in:
commit
875a2a98d6
52 files changed
+995
-518
No files matched your search
+55
-89
@@ -15428,45 +15428,6 @@ paths:
|
||||
"403": *a9
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
/api/settings/preview-features:
|
||||
get:
|
||||
tags:
|
||||
- Meta
|
||||
summary: Get the preview-feature opt-ins
|
||||
description: Every flag, off unless the caller switched it on.
|
||||
responses:
|
||||
"200":
|
||||
description: The opt-ins.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PreviewFeaturesEnvelope"
|
||||
"401": *a1
|
||||
"403": *a9
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
patch:
|
||||
tags:
|
||||
- Meta
|
||||
summary: Switch preview features on or off
|
||||
description: Sets the flags the body names and keeps the rest. An unknown flag or an empty body is refused with 422.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PreviewFeaturesPatchBody"
|
||||
responses:
|
||||
"200":
|
||||
description: The opt-ins as stored.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PreviewFeaturesSavedEnvelope"
|
||||
"401": *a1
|
||||
"403": *a9
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
/api/settings/privacy-summary:
|
||||
get:
|
||||
tags:
|
||||
@@ -27212,6 +27173,61 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
/api/settings/preview-features:
|
||||
get:
|
||||
security: []
|
||||
deprecated: true
|
||||
tags:
|
||||
- Retired
|
||||
summary: GET /api/settings/preview-features — removed in v1.46.0
|
||||
description: >-
|
||||
Removed in v1.46.0. Every request to this path answers 410 Gone, whatever the method and whatever credential it
|
||||
carries. There is no replacement; the capability is gone rather than moved.
|
||||
|
||||
|
||||
Stored per-account opt-ins to preview features, of which there was one, the topic overview pages. Those pages
|
||||
were dropped before they shipped, so the switch governed nothing; the route and its column are gone, and no
|
||||
preview is gated per account.
|
||||
|
||||
|
||||
Retiring a path is deliberate, so the answer says so rather than leaving a client to read a 404 as a broken
|
||||
deployment and retry forever. Treat 410 as terminal: drop the call, degrade the surface that depended on it, and
|
||||
do not offer a retry.
|
||||
responses:
|
||||
"410":
|
||||
description: The path was removed in v1.46.0 and will not answer again. `meta.errorCode` = `route.retired`,
|
||||
`meta.removedIn` carries the version, and `meta.replacedBy` carries the replacement path or null.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
patch:
|
||||
security: []
|
||||
deprecated: true
|
||||
tags:
|
||||
- Retired
|
||||
summary: PATCH /api/settings/preview-features — removed in v1.46.0
|
||||
description: >-
|
||||
Removed in v1.46.0. Every request to this path answers 410 Gone, whatever the method and whatever credential it
|
||||
carries. There is no replacement; the capability is gone rather than moved.
|
||||
|
||||
|
||||
Stored per-account opt-ins to preview features, of which there was one, the topic overview pages. Those pages
|
||||
were dropped before they shipped, so the switch governed nothing; the route and its column are gone, and no
|
||||
preview is gated per account.
|
||||
|
||||
|
||||
Retiring a path is deliberate, so the answer says so rather than leaving a client to read a 404 as a broken
|
||||
deployment and retry forever. Treat 410 as terminal: drop the call, degrade the surface that depended on it, and
|
||||
do not offer a retry.
|
||||
responses:
|
||||
"410":
|
||||
description: The path was removed in v1.46.0 and will not answer again. `meta.errorCode` = `route.retired`,
|
||||
`meta.removedIn` carries the version, and `meta.replacedBy` carries the replacement path or null.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
components:
|
||||
schemas:
|
||||
CreateCycleCustomSymptomRequest:
|
||||
@@ -33264,12 +33280,6 @@ components:
|
||||
- version
|
||||
- list
|
||||
additionalProperties: false
|
||||
PreviewFeaturesPatchBody:
|
||||
type: object
|
||||
properties:
|
||||
topics:
|
||||
type: boolean
|
||||
additionalProperties: false
|
||||
ConsentPostBody:
|
||||
type: object
|
||||
properties:
|
||||
@@ -73560,50 +73570,6 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
PreviewFeaturesEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/PreviewFeatures"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
PreviewFeatures:
|
||||
type: object
|
||||
properties:
|
||||
topics:
|
||||
type: boolean
|
||||
description: Topic overview pages above the single-metric Insights pages. Off by default.
|
||||
required:
|
||||
- topics
|
||||
additionalProperties: false
|
||||
description: Per-user opt-in to preview features, one boolean per flag, every flag off by default.
|
||||
PreviewFeaturesSavedEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/PreviewFeatures"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
PrivacySummaryEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -237,6 +237,21 @@ Pass `--only=prod` / `--only=demo` to check a single target, or
|
||||
`--attempts=N --interval=ms` to widen the retry window while a slow
|
||||
pull settles.
|
||||
|
||||
The worker loads each job's handler module on that job's first run, so a
|
||||
module the image cannot load does not stop the worker from starting. Once
|
||||
it has bound its queues, the worker loads every handler a single time in
|
||||
the background and reports each one that fails, normally within a minute
|
||||
of start. Each failure is a `job.run.failed` event at `error` with
|
||||
`reason: "handler_load_failed"` and the queue's name, and stderr also gets
|
||||
`[pg-boss] [registry] preloaded N/M job handlers; failed to load: <queues>`.
|
||||
If either shows up after a deploy, the image is broken for those queues,
|
||||
and their jobs will fail when they run. Roll back (see Rollback above)
|
||||
instead of waiting for the retries to run out:
|
||||
|
||||
```sh
|
||||
docker compose logs --since 5m app 2>&1 | grep -E 'handler_load_failed|failed to load:'
|
||||
```
|
||||
|
||||
## Walk the synthetic journey
|
||||
|
||||
The version check proves the instance serves the right build. It does not
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
-- Drop the per-user preview-feature opt-ins.
|
||||
--
|
||||
-- `users.preview_features_json` (0440) stored one flag, `topics`, for the
|
||||
-- topic overview pages. Those pages were dropped before they shipped, so no
|
||||
-- surface ever read the flag; `/api/settings/preview-features` is retired
|
||||
-- with this release and answers 410.
|
||||
--
|
||||
-- What is lost is a switch that switched nothing. A backup written by a
|
||||
-- release that still had the column carries the field, and the restore
|
||||
-- accepts and ignores it.
|
||||
--
|
||||
-- Dropping a column takes an ACCESS EXCLUSIVE lock on `users` for a catalogue
|
||||
-- update only (no rewrite). The lock wait is bounded so a long-running reader
|
||||
-- cannot queue every sign-in behind this statement; the deploy retries a
|
||||
-- timed-out attempt.
|
||||
--
|
||||
-- Reversibility: re-adding the nullable column restores the shape; every
|
||||
-- account then reads as all flags off, which is what every account had.
|
||||
--
|
||||
-- @retry-on-lock-timeout
|
||||
|
||||
DO $migration$
|
||||
BEGIN
|
||||
PERFORM set_config('lock_timeout', '5s', true);
|
||||
ALTER TABLE "users" DROP COLUMN IF EXISTS "preview_features_json";
|
||||
END
|
||||
$migration$;
|
||||
@@ -387,11 +387,6 @@ model User {
|
||||
// copies a stored browser value up once when the server has none for that list.
|
||||
// Shape: { "version": 1, "labs": { "view": "list", "order": ["<id>"], "sortDir": "manual" }, ... }
|
||||
moduleListLayoutJson Json? @map("module_list_layout_json")
|
||||
/// Per-user opt-in to preview features, one boolean per flag, all off by
|
||||
/// default (`src/lib/settings/preview-features.ts`). Read and written
|
||||
/// through `/api/settings/preview-features` and carried by the backup.
|
||||
/// @pending-consumer(topics preview) the `topics` flag is stored, but no surface reads it and the Settings > System switch is unlisted until the topic overview pages ship.
|
||||
previewFeaturesJson Json? @map("preview_features_json")
|
||||
/// Optional activity goals the person set for themselves: workouts, active
|
||||
/// minutes and steps per week, steps per month. Null or an absent key = no
|
||||
/// goal. Parsed by `src/lib/validations/activity-goals.ts`, written through
|
||||
|
||||
@@ -142,6 +142,14 @@ describe("background-task settle contract", () => {
|
||||
expect(pendingBackgroundTaskCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("drains a handle that is not a promise instead of spinning on it", async () => {
|
||||
// A mocked background starter that returns nothing hands the registry
|
||||
// `undefined`; left in the set, settle would loop on it forever.
|
||||
trackBackgroundTask(undefined as unknown as Promise<unknown>);
|
||||
await settleBackgroundTasks();
|
||||
expect(pendingBackgroundTaskCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("registers what fireAndForget starts", async () => {
|
||||
let release: () => void = () => {};
|
||||
fireAndForget(
|
||||
|
||||
@@ -2752,7 +2752,7 @@
|
||||
"lib/rollups/mood-rollups.ts": 4,
|
||||
"lib/rollups/tiered-context.ts": 6,
|
||||
"lib/score-config/rows.ts": 1,
|
||||
"lib/settings/section-slugs.ts": 20,
|
||||
"lib/settings/section-slugs.ts": 19,
|
||||
"lib/sharing/__tests__/account-access-parse.test.ts": 3,
|
||||
"lib/sharing/__tests__/account-access.test.ts": 2,
|
||||
"lib/sharing/__tests__/account-label.test.ts": 1,
|
||||
|
||||
@@ -91,3 +91,56 @@ describe("detached work cannot rename the request's event", () => {
|
||||
expect(startsDetached(detached)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Read-path work a request starts and never awaits. Each of these runs real
|
||||
* statements against Postgres after the response is already on its way, so a
|
||||
* bare `void` hides it from `settleBackgroundTasks()`: the statement counter
|
||||
* then closes its window while a stale account's rollup recompute is still
|
||||
* writing, and the next test inherits the tail. The dashboard summary budget
|
||||
* read 30 against 28 that way, depending on which side of the window a full
|
||||
* mood recompute landed.
|
||||
*/
|
||||
const READ_PATH_BACKGROUND_STARTERS = [
|
||||
"ensureUserRollupsFresh",
|
||||
"ensureUserMoodRollupsFresh",
|
||||
"requestTodayBriefingWarm",
|
||||
"touchGrantUsage",
|
||||
"recordDelegatedAccess",
|
||||
] as const;
|
||||
|
||||
describe("read-path background work is registered", () => {
|
||||
const files = readSourceFolder("src", {
|
||||
floor: 3000,
|
||||
include: (rel) =>
|
||||
!rel.startsWith("generated/") &&
|
||||
!rel.includes("__tests__/") &&
|
||||
!/\.test\.tsx?$/.test(rel),
|
||||
});
|
||||
const bare = new RegExp(
|
||||
String.raw`\bvoid\s+(${READ_PATH_BACKGROUND_STARTERS.join("|")})\s*\(`,
|
||||
);
|
||||
|
||||
it("starts none of them with a bare void", () => {
|
||||
const offenders = files.flatMap(([rel, text]) =>
|
||||
stripComments(text)
|
||||
.split("\n")
|
||||
.flatMap((line, i) => (bare.test(line) ? [`src/${rel}:${i + 1}`] : [])),
|
||||
);
|
||||
expect(
|
||||
offenders,
|
||||
"wrap the call in trackBackgroundTask(runDetached(() => ...)) or " +
|
||||
"fireAndForget(...) so the suite can await it",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("still finds each starter at a detached call site", () => {
|
||||
const joined = files.map(([, text]) => stripComments(text)).join("\n");
|
||||
for (const name of READ_PATH_BACKGROUND_STARTERS) {
|
||||
expect(
|
||||
new RegExp(String.raw`=>\s*${name}\s*\(`).test(joined),
|
||||
`${name} is no longer started detached anywhere; drop it from the list`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2163,8 +2163,6 @@ const ACTOR_ROUTES: Record<string, string> = {
|
||||
"The UI language belongs to the person reading the screen, never to the record on it. Under a record scope this would transplant a delegate's choice onto the owner's row and send the owner's cron mail in a language they may not read. It has to keep working rather than merely refuse safely: the switcher's mount-time backfill fires on every page load, including the shared dashboard.",
|
||||
"app/api/settings/module-list-layout/route.ts":
|
||||
"How the person's own labs, illness and checkups lists are laid out: card or list view, manual order, sort. It reads and writes the caller's own row only. Inside another record the ids of a saved order simply match nothing and the list keeps the server order, so serving the person's own choice there reveals nothing of the owner's and grants nothing; refusing it under a switch broke the list controls in exactly the context they are used in.",
|
||||
"app/api/settings/preview-features/route.ts":
|
||||
"The person's own opt-in to preview features. A preview entry is chrome of the person reading the screen, never a property of the record on it; it reads and writes the caller's own row and nothing of the owner's.",
|
||||
"app/api/feature-flags/route.ts":
|
||||
"Reads the `AppSettings` singleton and no user row at all, so there is no record for a switch to substitute. It answers about the deployment, which for this purpose is the caller's side of the request; the shell gates the Coach launcher and the assistant surfaces on it, so a 403 here is a piece of chrome deciding it does not exist.",
|
||||
};
|
||||
@@ -2273,8 +2271,11 @@ const ACTOR_ROUTES: Record<string, string> = {
|
||||
* side). The activity goals route does not: setting or changing goals stays
|
||||
* owner-only, and a delegate sees the goals only as the calendar reports them.
|
||||
* 270 -> 273.
|
||||
*
|
||||
* The preview-feature switch went with its column in the next release; the
|
||||
* topic pages it was kept for were dropped. 273 -> 272.
|
||||
*/
|
||||
const FROZEN_ENTRY_COUNT = 273;
|
||||
const FROZEN_ENTRY_COUNT = 272;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* GET / PATCH the caller's preview-feature opt-ins.
|
||||
*
|
||||
* GET answers every flag, off unless the caller switched it on. PATCH sets
|
||||
* the flags the body names and keeps the rest; unknown flags are refused
|
||||
* with the multi-issue 422 so a typo cannot silently do nothing. The blob
|
||||
* lives on its own `User` column (`preview_features_json`).
|
||||
*
|
||||
* The opt-ins are the caller's own, so this is an actor surface
|
||||
* (`requireActorAuth`): a browser acting for another record keeps reaching
|
||||
* the person's own switch. The PATCH reads and writes under a row lock, so two
|
||||
* flags switched at once both stay switched.
|
||||
*/
|
||||
import { apiHandler, requireActorAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { parseJsonBody } from "@/lib/api/body";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
PREVIEW_FEATURE_KEYS,
|
||||
resolvePreviewFeatures,
|
||||
type PreviewFeatures,
|
||||
} from "@/lib/settings/preview-features";
|
||||
import { previewFeaturesPatchSchema } from "@/lib/validations/settings-prefs";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function readStored(
|
||||
userId: string,
|
||||
db: Pick<typeof prisma, "user"> = prisma,
|
||||
): Promise<unknown> {
|
||||
const row = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { previewFeaturesJson: true },
|
||||
});
|
||||
return row?.previewFeaturesJson;
|
||||
}
|
||||
|
||||
function enabledFlags(features: PreviewFeatures): string[] {
|
||||
return PREVIEW_FEATURE_KEYS.filter((key) => features[key]);
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireActorAuth();
|
||||
const features = resolvePreviewFeatures(await readStored(user.id));
|
||||
annotate({
|
||||
action: { name: "settings.preview-features.read" },
|
||||
meta: { enabled: enabledFlags(features) },
|
||||
});
|
||||
return apiSuccess(features);
|
||||
});
|
||||
|
||||
export const PATCH = apiHandler(async (request: Request) => {
|
||||
const { user } = await requireActorAuth();
|
||||
|
||||
const parsed = await parseJsonBody(request, previewFeaturesPatchSchema, {
|
||||
maxBytes: 1024,
|
||||
action: "settings.preview-features.validation-failed",
|
||||
});
|
||||
if (!parsed.ok) return parsed.error;
|
||||
|
||||
const next = await prisma.$transaction(async (tx) => {
|
||||
await tx.$queryRaw`SELECT 1 FROM "users" WHERE "id" = ${user.id} FOR UPDATE`;
|
||||
const current = resolvePreviewFeatures(await readStored(user.id, tx));
|
||||
const merged: PreviewFeatures = {
|
||||
topics: parsed.data.topics ?? current.topics,
|
||||
};
|
||||
await tx.user.update({
|
||||
where: { id: user.id },
|
||||
data: { previewFeaturesJson: toJson(merged) },
|
||||
});
|
||||
return merged;
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "settings.preview-features.update" },
|
||||
meta: { enabled: enabledFlags(next) },
|
||||
});
|
||||
return apiSuccess(next);
|
||||
});
|
||||
@@ -22,6 +22,8 @@ vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn() },
|
||||
// The mirror walk's token lookup: no token switched, so no walk.
|
||||
apiToken: { findMany: vi.fn(async () => []) },
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
cycleDayLog: { findMany: vi.fn() },
|
||||
|
||||
@@ -31,7 +31,14 @@ vi.mock("@/lib/rollups/mood-rollups", async () => {
|
||||
|
||||
import { buildMoodDailySeries } from "../mood-series";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { readMoodDayRollups } from "@/lib/rollups/mood-rollups";
|
||||
import {
|
||||
ensureUserMoodRollupsFresh,
|
||||
readMoodDayRollups,
|
||||
} from "@/lib/rollups/mood-rollups";
|
||||
import {
|
||||
pendingBackgroundTaskCount,
|
||||
settleBackgroundTasks,
|
||||
} from "@/lib/logging/background-tasks";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
@@ -104,4 +111,26 @@ describe("buildMoodDailySeries", () => {
|
||||
expect(series.source).toBe("rollup");
|
||||
expect(series.summary.count).toBe(0);
|
||||
});
|
||||
|
||||
it("files the rollup warm-up with the background registry", async () => {
|
||||
// The warm-up is not awaited by the read, so a caller that has to know
|
||||
// when the request's work is over (the statement counter, the suite's
|
||||
// settle hook) can only see it through the registry. Left unregistered,
|
||||
// a stale account's full recompute bled into whatever ran next.
|
||||
let release: () => void = () => {};
|
||||
vi.mocked(ensureUserMoodRollupsFresh).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
release = () => resolve({ recomputed: true });
|
||||
}),
|
||||
);
|
||||
|
||||
await buildMoodDailySeries("user-1");
|
||||
|
||||
expect(ensureUserMoodRollupsFresh).toHaveBeenCalledWith("user-1");
|
||||
expect(pendingBackgroundTaskCount()).toBe(1);
|
||||
release();
|
||||
await settleBackgroundTasks();
|
||||
expect(pendingBackgroundTaskCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,8 @@
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { runDetached } from "@/lib/logging/detached";
|
||||
import { trackBackgroundTask } from "@/lib/logging/background-tasks";
|
||||
import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
||||
import {
|
||||
ensureUserMoodRollupsFresh,
|
||||
@@ -94,7 +96,9 @@ export async function buildMoodDailySeries(
|
||||
): Promise<MoodDailySeries> {
|
||||
// Fire-and-forget warm-up so the next cold mount for this user lands on
|
||||
// the rollup tier even when the boot-time backfill hasn't reached them.
|
||||
void ensureUserMoodRollupsFresh(userId);
|
||||
// Registered so a caller that must know when the request's work is over
|
||||
// (the suite's settle hook, the statement counter) can await it.
|
||||
trackBackgroundTask(runDetached(() => ensureUserMoodRollupsFresh(userId)));
|
||||
|
||||
const since = new Date(Date.now() - MOOD_SERIES_WINDOW_MS);
|
||||
const rollups = await readMoodDayRollups(userId, since);
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* the sleep stages. The stages run in the order the route always ran them.
|
||||
*/
|
||||
import type { SessionUser } from "@/lib/auth/session-user";
|
||||
import { runDetached } from "@/lib/logging/detached";
|
||||
import { trackBackgroundTask } from "@/lib/logging/background-tasks";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
@@ -42,7 +44,7 @@ export async function buildAnalyticsResponse(
|
||||
// measurement lands, and the next request returns the up-to-date
|
||||
// value once the background refresh completes. `ensureUserRollupsFresh`
|
||||
// wraps its own try/catch, so the void call cannot reject.
|
||||
void ensureUserRollupsFresh(user.id);
|
||||
trackBackgroundTask(runDetached(() => ensureUserRollupsFresh(user.id)));
|
||||
|
||||
// Single per-type coverage probe shared by the
|
||||
// bp_in_target / healthScore / correlations branches below. The
|
||||
|
||||
@@ -59,6 +59,8 @@ import pLimit from "p-limit";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { runDetached } from "@/lib/logging/detached";
|
||||
import { trackBackgroundTask } from "@/lib/logging/background-tasks";
|
||||
import {
|
||||
HOURLY_MEAN_DAY_TYPES,
|
||||
usesHourlyMeanDay,
|
||||
@@ -277,14 +279,12 @@ export async function computeSummariesSlice(
|
||||
// One clock for every window of the slice, JS and SQL alike (see
|
||||
// `request-clock-sql.ts`).
|
||||
const now = new Date();
|
||||
// v1.4.37.1 — fire-and-forget. See `src/app/api/analytics/route.ts`
|
||||
// for the full rationale: awaiting this on the read path can stall
|
||||
// the event loop for tens of seconds on tenants with large iOS step
|
||||
// sample sets that keep the 90-day window slightly stale. The downstream
|
||||
// coverage probe falls back to live SQL for uncovered types, so
|
||||
// correctness is preserved; the read just doesn't block waiting
|
||||
// for the background refresh.
|
||||
void ensureUserRollupsFresh(userId);
|
||||
// v1.4.37.1 — fire-and-forget (see `src/app/api/analytics/route.ts`):
|
||||
// awaiting it can stall the event loop for tens of seconds on accounts
|
||||
// whose iOS steps keep the 90-day window stale, and the coverage probe
|
||||
// falls back to live SQL meanwhile. Registered so the suite's settle hook
|
||||
// and the statement counter can await it.
|
||||
trackBackgroundTask(runDetached(() => ensureUserRollupsFresh(userId)));
|
||||
|
||||
// v1.4.36 — per-type coverage probe replaces the legacy global
|
||||
// COUNT. The previous gate returned true as soon as ANY type had at
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { AccountGrant } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { runDetached } from "@/lib/logging/detached";
|
||||
import { trackBackgroundTask } from "@/lib/logging/background-tasks";
|
||||
import { SESSION_USER_SELECT, type SessionUser } from "@/lib/auth/session-user";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { recordDelegatedAccess } from "@/lib/auth/audit";
|
||||
@@ -372,13 +374,15 @@ async function resolveSwitchedRecord(
|
||||
});
|
||||
// Fire-and-forget, the `ApiToken.lastUsedAt` posture: the read must not wait
|
||||
// on the bookkeeping, and the bookkeeping failing must not fail the read.
|
||||
void touchGrantUsage(grant.id);
|
||||
trackBackgroundTask(runDetached(() => touchGrantUsage(grant.id)));
|
||||
// The owner's copy of the same fact, and the one they can read: a day-
|
||||
// coalesced row on their own record saying who opened it. It lands here
|
||||
// rather than in the handlers because a route cannot forget what it never
|
||||
// had to remember — every delegated request in the product passes through
|
||||
// this line, and only requests that got past the grant check reach it.
|
||||
void recordDelegatedAccess(owner.id, auth.user.id);
|
||||
trackBackgroundTask(
|
||||
runDetached(() => recordDelegatedAccess(owner.id, auth.user.id)),
|
||||
);
|
||||
|
||||
return {
|
||||
session: auth.session,
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
import type { SessionUser } from "@/lib/auth/session-user";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { runDetached } from "@/lib/logging/detached";
|
||||
import { trackBackgroundTask } from "@/lib/logging/background-tasks";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { dashboardSnapshotCacheKey } from "@/lib/cache/invalidate";
|
||||
@@ -201,14 +203,18 @@ export async function readDashboardSnapshotCached(
|
||||
// its claim, never thrown.
|
||||
if (briefingAi.available) {
|
||||
const lastSeen = cached.tiles?.lastSeenByType ?? {};
|
||||
void requestTodayBriefingWarm({
|
||||
userId: user.id,
|
||||
locale,
|
||||
timezone: user.timezone,
|
||||
generatedAt,
|
||||
lastSeenAt: (type) => lastSeen[type]?.lastSeenAt ?? null,
|
||||
now,
|
||||
}).catch(() => undefined);
|
||||
trackBackgroundTask(
|
||||
runDetached(() =>
|
||||
requestTodayBriefingWarm({
|
||||
userId: user.id,
|
||||
locale,
|
||||
timezone: user.timezone,
|
||||
generatedAt,
|
||||
lastSeenAt: (type) => lastSeen[type]?.lastSeenAt ?? null,
|
||||
now,
|
||||
}),
|
||||
).catch(() => undefined),
|
||||
);
|
||||
}
|
||||
|
||||
return { body, locale };
|
||||
|
||||
@@ -516,8 +516,6 @@ export const USER_KEPT_FIELDS: Readonly<Record<string, string>> = {
|
||||
oidcIssuer: "a sign-in credential — the external identity binding",
|
||||
oidcSub: "a sign-in credential — the external identity binding",
|
||||
createdAt: "when the account was created; the account is preserved",
|
||||
previewFeaturesJson:
|
||||
"which preview features the person opted into — how the interface is shown, not record content",
|
||||
updatedAt: "row bookkeeping maintained by Prisma",
|
||||
managedProfileAt:
|
||||
"whether this record is one somebody else runs — account shape, not record content. Clearing it would strip the guardian's reach from a record that has no credentials of its own, leaving an account nobody can open, administer or delete. Emptying a record is not emancipating the person in it.",
|
||||
|
||||
@@ -111,6 +111,21 @@ describe("admitAccountSettings", () => {
|
||||
expect(admitAccountSettings({}, ctx)).toEqual({ data: {}, refused: [] });
|
||||
});
|
||||
|
||||
it("accepts and ignores the retired preview-feature opt-ins of an older file", async () => {
|
||||
// Written by v1.44 and v1.45, whose column v1.46 dropped. The file must
|
||||
// still restore, so the field is neither refused nor written.
|
||||
const { accountSettingsBackupSchema } =
|
||||
await import("@/lib/export/account-settings-backup-schema");
|
||||
const entry = accountSettingsBackupSchema.parse({
|
||||
timezone: "Europe/Berlin",
|
||||
previewFeaturesJson: { topics: true },
|
||||
});
|
||||
expect(admitAccountSettings(entry, ctx)).toEqual({
|
||||
data: { timezone: "Europe/Berlin" },
|
||||
refused: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a language, unit or time zone this release does not know", () => {
|
||||
const { data, refused } = admitAccountSettings(
|
||||
{
|
||||
|
||||
@@ -66,6 +66,9 @@ export const accountSettingsBackupSchema = z
|
||||
moodTagLayoutJson: z.unknown().optional(),
|
||||
documentsLayoutJson: z.unknown().optional(),
|
||||
moduleListLayoutJson: z.unknown().optional(),
|
||||
// Retired in v1.46.0 with its column (the preview-feature opt-ins). A file
|
||||
// written by v1.44 or v1.45 still carries it; it is accepted and ignored,
|
||||
// because no codec reads it, so such a file restores everything else.
|
||||
previewFeaturesJson: z.unknown().optional(),
|
||||
activityGoalsJson: z.unknown().optional(),
|
||||
reportSelectionJson: z.unknown().optional(),
|
||||
|
||||
@@ -539,7 +539,6 @@ const ACCOUNT_SETTING_CODECS = {
|
||||
moodTagLayoutJson: json("moodTagLayoutJson"),
|
||||
documentsLayoutJson: json("documentsLayoutJson"),
|
||||
moduleListLayoutJson: json("moduleListLayoutJson"),
|
||||
previewFeaturesJson: json("previewFeaturesJson"),
|
||||
activityGoalsJson: activityGoals,
|
||||
reportSelectionJson: json("reportSelectionJson"),
|
||||
globalExcludedInjectionSites: excludedSites,
|
||||
|
||||
@@ -160,6 +160,14 @@ export const RETIRED_ROUTES: readonly RetiredRoute[] = [
|
||||
"Research Mode was an opt-in for the estimated drug-level curve. The chart had stopped consulting the flag several releases earlier and painted for every account regardless, so the switch governed nothing. The curve is simply part of the medication page now and needs no preference read.",
|
||||
methods: ["GET", "POST", "DELETE"],
|
||||
},
|
||||
{
|
||||
path: "/api/settings/preview-features",
|
||||
removedIn: "1.46.0",
|
||||
replacedBy: null,
|
||||
reason:
|
||||
"Stored per-account opt-ins to preview features, of which there was one, the topic overview pages. Those pages were dropped before they shipped, so the switch governed nothing; the route and its column are gone, and no preview is gated per account.",
|
||||
methods: ["GET", "PATCH"],
|
||||
},
|
||||
];
|
||||
|
||||
const BY_PATH: ReadonlyMap<string, RetiredRoute> = new Map(
|
||||
|
||||
@@ -98,6 +98,8 @@
|
||||
* shape `summarize()` would return.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { runDetached } from "@/lib/logging/detached";
|
||||
import { trackBackgroundTask } from "@/lib/logging/background-tasks";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import {
|
||||
liveTrend,
|
||||
@@ -254,14 +256,12 @@ export async function buildComprehensiveAggregate(
|
||||
// bp-raw, first-at, coverage-probe) without re-instrumenting.
|
||||
const timings: Record<string, number> = {};
|
||||
|
||||
// v1.4.37.1 — fire-and-forget. See `src/app/api/analytics/route.ts`
|
||||
// for the full rationale: awaiting this on the read path can stall
|
||||
// the event loop for 30–60 s on power-user accounts whose iOS step
|
||||
// samples keep the 90-day window slightly stale. The downstream
|
||||
// coverage probe falls back to live SQL for uncovered types, so
|
||||
// correctness is preserved; the read just doesn't block waiting
|
||||
// for the background refresh.
|
||||
void ensureUserRollupsFresh(userId);
|
||||
// v1.4.37.1 — fire-and-forget (see `src/app/api/analytics/route.ts`):
|
||||
// awaiting it can stall the event loop for 30–60 s on accounts whose iOS
|
||||
// steps keep the 90-day window stale, and the coverage probe falls back to
|
||||
// live SQL meanwhile. Registered so the suite's settle hook and the
|
||||
// statement counter can await it.
|
||||
trackBackgroundTask(runDetached(() => ensureUserRollupsFresh(userId)));
|
||||
|
||||
// v1.4.36 — per-type coverage probe. The legacy global COUNT
|
||||
// returned true as soon as any one type had a DAY bucket, which made
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
* left on it deliberately (see their call sites in `build.ts`).
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { runDetached } from "@/lib/logging/detached";
|
||||
import { trackBackgroundTask } from "@/lib/logging/background-tasks";
|
||||
import type { DataPoint } from "@/lib/analytics/trends";
|
||||
import {
|
||||
ensureUserMoodRollupsFresh,
|
||||
@@ -41,7 +43,7 @@ export async function readOverviewMoodSeries(
|
||||
userId: string,
|
||||
since: Date,
|
||||
): Promise<OverviewMoodSeries> {
|
||||
void ensureUserMoodRollupsFresh(userId);
|
||||
trackBackgroundTask(runDetached(() => ensureUserMoodRollupsFresh(userId)));
|
||||
const moodRollupDayRows = await readMoodDayRollups(userId, since);
|
||||
if (moodRollupDayRows.length > 0) {
|
||||
return {
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* Split out of `features.ts`, which builds the block from them.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { runDetached } from "@/lib/logging/detached";
|
||||
import { trackBackgroundTask } from "@/lib/logging/background-tasks";
|
||||
import {
|
||||
ensureUserMoodRollupsFresh,
|
||||
readMoodDayRollups,
|
||||
@@ -39,7 +41,7 @@ export async function readMoodDailyPoints(
|
||||
totalEntries: number;
|
||||
latestScore: number | null;
|
||||
}> {
|
||||
void ensureUserMoodRollupsFresh(userId);
|
||||
trackBackgroundTask(runDetached(() => ensureUserMoodRollupsFresh(userId)));
|
||||
// Five-year window mirrors the mood-rollup writer default; covers
|
||||
// every realistic user history span without an unbounded scan.
|
||||
const fiveYearsAgo = new Date(Date.now() - 5 * 365 * 24 * 60 * 60 * 1000);
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* The end of a provider's full-history backfill marks the read caches stale.
|
||||
*
|
||||
* The per-write tail always did (through `afterMeasurementMutation`), but the
|
||||
* backfill tail refolded the rollups and re-warmed the status cards without
|
||||
* touching the analytics caches, so the dashboard and the analytics reads went
|
||||
* on serving the pre-backfill body until its TTL ran out.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/rollups/measurement-rollups", () => ({
|
||||
collapseToTypeDayKeys: (keys: Array<{ type: string; measuredAt: Date }>) =>
|
||||
keys,
|
||||
recomputeUserRollups: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMeasurements: vi.fn(),
|
||||
invalidateUserDashboardSnapshot: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/insights/status-invalidation", () => ({
|
||||
invalidateStatusInsightsForTypes: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/after-measurement-mutation", () => ({
|
||||
afterMeasurementMutation: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/arrivals/measurement-emit", () => ({
|
||||
emitInsertedMeasurementArrivals: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/status-invalidation";
|
||||
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
|
||||
|
||||
import { afterProviderBackfill } from "../after-write";
|
||||
|
||||
const keys = [
|
||||
{ type: "WEIGHT" as const, measuredAt: new Date("2026-03-05T00:00:00Z") },
|
||||
{ type: "PULSE" as const, measuredAt: new Date("2026-01-10T00:00:00Z") },
|
||||
{ type: "WEIGHT" as const, measuredAt: new Date("2026-02-01T00:00:00Z") },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("afterProviderBackfill", () => {
|
||||
it("marks the user's read caches stale back to the first touched day", async () => {
|
||||
await afterProviderBackfill({ userId: "u1", provider: "fitbit", keys });
|
||||
|
||||
expect(recomputeUserRollups).toHaveBeenCalledWith("u1", {
|
||||
types: ["WEIGHT", "PULSE"],
|
||||
from: new Date("2026-01-10T00:00:00Z"),
|
||||
to: new Date("2026-03-06T00:00:00Z"),
|
||||
});
|
||||
expect(invalidateUserMeasurements).toHaveBeenCalledTimes(1);
|
||||
expect(invalidateUserMeasurements).toHaveBeenCalledWith("u1", {
|
||||
touchedFrom: new Date("2026-01-10T00:00:00Z"),
|
||||
});
|
||||
// After the fold, so a reader rebuilds against the new rollups.
|
||||
expect(
|
||||
vi.mocked(invalidateUserMeasurements).mock.invocationCallOrder[0],
|
||||
).toBeGreaterThan(
|
||||
vi.mocked(recomputeUserRollups).mock.invocationCallOrder[0]!,
|
||||
);
|
||||
expect(invalidateStatusInsightsForTypes).toHaveBeenCalledWith(
|
||||
"u1",
|
||||
["WEIGHT", "PULSE"],
|
||||
{ source: "sync" },
|
||||
);
|
||||
});
|
||||
|
||||
it("marks them stale even when the fold fails, and still throws for the caller", async () => {
|
||||
vi.mocked(recomputeUserRollups).mockRejectedValueOnce(new Error("fold"));
|
||||
|
||||
await expect(
|
||||
afterProviderBackfill({ userId: "u1", provider: "fitbit", keys }),
|
||||
).rejects.toThrow("fold");
|
||||
expect(invalidateUserMeasurements).toHaveBeenCalledWith("u1", {
|
||||
touchedFrom: new Date("2026-01-10T00:00:00Z"),
|
||||
});
|
||||
});
|
||||
|
||||
it("does nothing for a backfill that touched nothing", async () => {
|
||||
await afterProviderBackfill({ userId: "u1", provider: "fitbit", keys: [] });
|
||||
expect(recomputeUserRollups).not.toHaveBeenCalled();
|
||||
expect(invalidateUserMeasurements).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,10 @@ import {
|
||||
emitInsertedMeasurementArrivals,
|
||||
type InsertedMeasurementArrivalRow,
|
||||
} from "@/lib/arrivals/measurement-emit";
|
||||
import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate";
|
||||
import {
|
||||
invalidateUserDashboardSnapshot,
|
||||
invalidateUserMeasurements,
|
||||
} from "@/lib/cache/invalidate";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/status-invalidation";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
|
||||
@@ -116,8 +119,14 @@ export function invalidateProviderStatus(
|
||||
|
||||
/**
|
||||
* The end of a full-history backfill: refold the whole touched range once
|
||||
* (from the first touched day to the end of the last), then re-warm the
|
||||
* touched status cards. Throws when the fold fails, for the caller to count.
|
||||
* (from the first touched day to the end of the last), mark the read caches
|
||||
* stale back to the first touched day, then re-warm the touched status cards.
|
||||
* Throws when the fold fails, for the caller to count.
|
||||
*
|
||||
* The caches are marked even when the fold fails: the rows changed either
|
||||
* way, and a cached analytics body built before the backfill would otherwise
|
||||
* be served until its TTL ran out. That is the same order and the same
|
||||
* posture as the per-write tail (`afterMeasurementMutation`).
|
||||
*/
|
||||
export async function afterProviderBackfill(input: {
|
||||
userId: string;
|
||||
@@ -135,6 +144,10 @@ export async function afterProviderBackfill(input: {
|
||||
// The keys are UTC day starts; the end is past the last touched day so the
|
||||
// aggregator's `< to` bound covers it.
|
||||
const to = new Date(sorted[sorted.length - 1]! + 24 * 60 * 60 * 1000);
|
||||
await recomputeUserRollups(input.userId, { types, from, to });
|
||||
try {
|
||||
await recomputeUserRollups(input.userId, { types, from, to });
|
||||
} finally {
|
||||
invalidateUserMeasurements(input.userId, { touchedFrom: from });
|
||||
}
|
||||
invalidateProviderStatus(input.userId, input.provider, types);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* The worker's boot preload of every job handler module.
|
||||
*
|
||||
* Handlers load on their queue's first job, so a module that cannot load
|
||||
* stopped failing the boot and started failing jobs instead: the queue burned
|
||||
* its retries, and a one-shot job (a restore, an import tail) ended failed
|
||||
* with nobody told at deploy time. The worker now loads every handler once in
|
||||
* the background after binding and reports each failure by queue name.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Job, PgBoss } from "pg-boss";
|
||||
|
||||
const bound = vi.hoisted(
|
||||
() => new Map<string, (jobs: unknown[]) => Promise<unknown>>(),
|
||||
);
|
||||
|
||||
vi.mock("@/lib/jobs/reminder/registrar-shared", () => ({
|
||||
createAndSchedule: vi.fn(async () => undefined),
|
||||
retireQueues: vi.fn(async () => undefined),
|
||||
createAndWork: vi.fn(
|
||||
async (
|
||||
_boss: unknown,
|
||||
queue: string,
|
||||
_opts: unknown,
|
||||
handler: (jobs: unknown[]) => Promise<unknown>,
|
||||
) => {
|
||||
bound.set(queue, handler);
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jobs/reminder/shared", () => ({ workerLog: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/logging/signal", () => ({ emitSignal: vi.fn() }));
|
||||
vi.mock("@/lib/jobs/report-worker-error", () => ({
|
||||
reportWorkerError: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("@/lib/jobs/job-failures", () => ({
|
||||
readConsecutiveRunFailures: vi.fn(async () => 0),
|
||||
}));
|
||||
|
||||
import { workerLog } from "@/lib/jobs/reminder/shared";
|
||||
import { emitSignal } from "@/lib/logging/signal";
|
||||
import { reportWorkerError } from "@/lib/jobs/report-worker-error";
|
||||
import { reportHandlerLoadFailure } from "@/lib/jobs/run-job";
|
||||
|
||||
import { readSource } from "@/__tests__/helpers/source-files";
|
||||
|
||||
import type { JobDefinition, JobGroup } from "../define-job";
|
||||
import {
|
||||
preloadHandlers,
|
||||
registerJobGroup,
|
||||
type BoundLoader,
|
||||
} from "../register";
|
||||
|
||||
const boss = {} as PgBoss;
|
||||
|
||||
function definition(
|
||||
queue: string,
|
||||
load: () => Promise<unknown>,
|
||||
): JobDefinition {
|
||||
return { queue, work: { batchSize: 1 }, load } as unknown as JobDefinition;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
bound.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("preloading the job handlers at worker boot", () => {
|
||||
it("reports a module that fails to load, by queue, and loads the rest", async () => {
|
||||
const ok = vi.fn(async () => async () => ({ ok: true as const }));
|
||||
const broken = vi.fn(async () => {
|
||||
throw new Error("Cannot find module './gone'");
|
||||
});
|
||||
const group = {
|
||||
name: "maintenance",
|
||||
jobs: [
|
||||
definition("first-ok", ok),
|
||||
definition("broken-module", broken),
|
||||
definition("second-ok", ok),
|
||||
],
|
||||
} as unknown as JobGroup;
|
||||
|
||||
const loaders: BoundLoader[] = [];
|
||||
await registerJobGroup(boss, group, (b) => loaders.push(b));
|
||||
expect(loaders.map((b) => b.queue)).toEqual([
|
||||
"first-ok",
|
||||
"broken-module",
|
||||
"second-ok",
|
||||
]);
|
||||
|
||||
const report = vi.fn(async () => undefined);
|
||||
const failed = await preloadHandlers(loaders, report);
|
||||
|
||||
expect(failed).toEqual(["broken-module"]);
|
||||
expect(report).toHaveBeenCalledTimes(1);
|
||||
expect(report).toHaveBeenCalledWith(
|
||||
"broken-module",
|
||||
expect.objectContaining({ message: "Cannot find module './gone'" }),
|
||||
);
|
||||
expect(ok).toHaveBeenCalledTimes(2);
|
||||
expect(workerLog).toHaveBeenCalledWith(
|
||||
"error",
|
||||
expect.stringContaining("failed to load: broken-module"),
|
||||
);
|
||||
});
|
||||
|
||||
it("preloads through the loader the queue is bound to, so the first job does not import again", async () => {
|
||||
const handler = vi.fn(async () => ({ ok: true as const }));
|
||||
const load = vi.fn(async () => handler);
|
||||
const group = {
|
||||
name: "maintenance",
|
||||
jobs: [definition("warm", load)],
|
||||
} as unknown as JobGroup;
|
||||
|
||||
const loaders: BoundLoader[] = [];
|
||||
await registerJobGroup(boss, group, (b) => loaders.push(b));
|
||||
await preloadHandlers(loaders, vi.fn());
|
||||
await bound.get("warm")!([{ id: "j1", data: {} } as Job<unknown>]);
|
||||
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not stop the sweep when a report itself fails", async () => {
|
||||
const loaders: BoundLoader[] = [
|
||||
{ queue: "a", load: () => Promise.reject(new Error("a")) },
|
||||
{ queue: "b", load: () => Promise.reject(new Error("b")) },
|
||||
];
|
||||
const report = vi.fn(async () => {
|
||||
throw new Error("sink down");
|
||||
});
|
||||
await expect(preloadHandlers(loaders, report)).resolves.toEqual(["a", "b"]);
|
||||
expect(report).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("reports a load failure on the job.run.failed line, at error, naming the queue", async () => {
|
||||
const error = new Error("SyntaxError in handler");
|
||||
await reportHandlerLoadFailure("broken-module", error);
|
||||
|
||||
expect(emitSignal).toHaveBeenCalledWith({
|
||||
action: "job.run.failed",
|
||||
level: "error",
|
||||
meta: {
|
||||
queue: "broken-module",
|
||||
reason: "handler_load_failed",
|
||||
consecutiveFailures: 0,
|
||||
},
|
||||
error,
|
||||
});
|
||||
expect(reportWorkerError).toHaveBeenCalledWith("broken-module", error, {
|
||||
phase: "handler_preload",
|
||||
});
|
||||
});
|
||||
|
||||
it("is started by the worker after binding, without holding up the boot", () => {
|
||||
const source = readSource("src/lib/jobs/reminder-worker.ts", [
|
||||
"registry.registerAllJobGroups(",
|
||||
"void registry.preloadHandlers(bound)",
|
||||
]);
|
||||
const register = source.indexOf("registry.registerAllJobGroups(");
|
||||
const preload = source.indexOf("void registry.preloadHandlers(bound)");
|
||||
expect(register).toBeGreaterThan(-1);
|
||||
expect(preload).toBeGreaterThan(register);
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,13 @@ import type { JobDefinition, JobGroup } from "./define-job";
|
||||
import { INTEGRATION_SYNC_GROUP } from "./integration-sync";
|
||||
import { MAINTENANCE_GROUP } from "./maintenance";
|
||||
import { REMINDERS_GROUP } from "./reminders";
|
||||
import { registerJobGroup, runBootDiscovery } from "./register";
|
||||
import {
|
||||
registerJobGroup,
|
||||
runBootDiscovery,
|
||||
type BoundLoader,
|
||||
} from "./register";
|
||||
|
||||
export { preloadHandlers, type BoundLoader } from "./register";
|
||||
import { ROLLUP_GROUP } from "./rollup";
|
||||
import { STATUS_GROUP } from "./status";
|
||||
|
||||
@@ -29,16 +35,18 @@ export function allJobDefinitions(): readonly JobDefinition[] {
|
||||
|
||||
/**
|
||||
* Register every group in order. `onGroup` runs before each one, so the boot
|
||||
* can time the groups as steps. Returns every queue name created.
|
||||
* can time the groups as steps; `onBound` receives every queue's handler
|
||||
* loader as it is bound. Returns every queue name created.
|
||||
*/
|
||||
export async function registerAllJobGroups(
|
||||
boss: PgBoss,
|
||||
onGroup: (group: JobGroup) => void = () => {},
|
||||
onBound: (bound: BoundLoader) => void = () => {},
|
||||
): Promise<string[]> {
|
||||
const queues: string[] = [];
|
||||
for (const group of JOB_GROUPS) {
|
||||
onGroup(group);
|
||||
queues.push(...(await registerJobGroup(boss, group)));
|
||||
queues.push(...(await registerJobGroup(boss, group, onBound)));
|
||||
}
|
||||
return queues;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,14 @@
|
||||
* queue's protections do not depend on whether its module has loaded yet. A
|
||||
* module that fails to load fails the job that asked for it, with the queue's
|
||||
* name on the error, and the next job tries the import again.
|
||||
*
|
||||
* Loading lazily moved a broken module from a failed boot to a failed job: the
|
||||
* worker comes up healthy, and the queue burns its retries the first time it
|
||||
* has work, which for a one-shot job (a restore, an import tail) means the
|
||||
* job ends failed with nobody told at deploy time. `preloadHandlers` closes
|
||||
* that gap. The worker runs it once after binding, in the background, through
|
||||
* the same memoised loader the queue uses, and reports every module that does
|
||||
* not load before any job needs it.
|
||||
*/
|
||||
import type { Job, PgBoss } from "pg-boss";
|
||||
|
||||
@@ -26,6 +34,7 @@ import {
|
||||
type ScheduleEntry,
|
||||
} from "@/lib/jobs/reminder/registrar-shared";
|
||||
import { workerLog } from "@/lib/jobs/reminder/shared";
|
||||
import { reportHandlerLoadFailure } from "@/lib/jobs/run-job";
|
||||
|
||||
import type {
|
||||
BatchHandler,
|
||||
@@ -35,6 +44,34 @@ import type {
|
||||
JobGroup,
|
||||
} from "./define-job";
|
||||
|
||||
/** A queue's handler loader, memoised: one import, retried after a failure. */
|
||||
export type HandlerLoader = () => Promise<BatchHandler<Job<unknown>>>;
|
||||
|
||||
/** One bound queue and the loader its handler goes through. */
|
||||
export interface BoundLoader {
|
||||
readonly queue: string;
|
||||
readonly load: HandlerLoader;
|
||||
}
|
||||
|
||||
/**
|
||||
* The loader a definition's binding shares: the first call imports, every
|
||||
* later call answers from the same promise, and a failed import is forgotten
|
||||
* so the next call tries again.
|
||||
*/
|
||||
export function handlerLoader(
|
||||
definition: JobDefinition,
|
||||
ctx: JobContext,
|
||||
): HandlerLoader {
|
||||
let loading: Promise<BatchHandler<Job<unknown>>> | null = null;
|
||||
return () => {
|
||||
loading ??= definition.load(ctx).catch((err: unknown) => {
|
||||
loading = null;
|
||||
throw err;
|
||||
});
|
||||
return loading;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The handler a definition binds: its loaded module behind the lock and the
|
||||
* lane it declares.
|
||||
@@ -42,15 +79,8 @@ import type {
|
||||
export function bindableHandler(
|
||||
definition: JobDefinition,
|
||||
ctx: JobContext,
|
||||
load: HandlerLoader = handlerLoader(definition, ctx),
|
||||
): BatchHandler<Job<unknown>> {
|
||||
let loading: Promise<BatchHandler<Job<unknown>>> | null = null;
|
||||
const load = (): Promise<BatchHandler<Job<unknown>>> => {
|
||||
loading ??= definition.load(ctx).catch((err: unknown) => {
|
||||
loading = null;
|
||||
throw err;
|
||||
});
|
||||
return loading;
|
||||
};
|
||||
let handler: BatchHandler<Job<unknown>> = async (jobs) =>
|
||||
(await load())(jobs);
|
||||
if (definition.lane) {
|
||||
@@ -88,11 +118,13 @@ export function policiesOf(jobs: readonly JobDefinition[]): QueuePolicyTable {
|
||||
|
||||
/**
|
||||
* Register one group: create, schedule, retire, bind. Returns the queue names
|
||||
* it created.
|
||||
* it created. `onBound` is handed each queue's loader as it is bound, so the
|
||||
* boot can preload through the very loader the queue will use.
|
||||
*/
|
||||
export async function registerJobGroup(
|
||||
boss: PgBoss,
|
||||
group: JobGroup,
|
||||
onBound: (bound: BoundLoader) => void = () => {},
|
||||
): Promise<readonly string[]> {
|
||||
const queues = group.jobs.map((job) => job.queue);
|
||||
await createAndSchedule(
|
||||
@@ -104,7 +136,14 @@ export async function registerJobGroup(
|
||||
if (group.retired) await retireQueues(boss, group.retired);
|
||||
const ctx: JobContext = { boss };
|
||||
for (const job of group.jobs) {
|
||||
await createAndWork(boss, job.queue, job.work, bindableHandler(job, ctx));
|
||||
const load = handlerLoader(job, ctx);
|
||||
await createAndWork(
|
||||
boss,
|
||||
job.queue,
|
||||
job.work,
|
||||
bindableHandler(job, ctx, load),
|
||||
);
|
||||
onBound({ queue: job.queue, load });
|
||||
}
|
||||
if (group.afterBind) await group.afterBind(ctx);
|
||||
return queues;
|
||||
@@ -144,3 +183,45 @@ export async function runBootDiscovery(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load every bound handler once, in declaration order, and report each one
|
||||
* that fails: `job.run.failed` at error with the queue's name and the reason
|
||||
* `handler_load_failed`, the line the repeated-failure alert already keys on,
|
||||
* plus the worker error report (stderr and GlitchTip). Never throws, and the
|
||||
* caller does not wait for it, so a broken module costs the boot nothing but
|
||||
* the report. A module that loads stays loaded, and the queue's first job
|
||||
* finds it in the loader's memo. Returns the queues that failed.
|
||||
*/
|
||||
export async function preloadHandlers(
|
||||
bound: readonly BoundLoader[],
|
||||
report: (
|
||||
queue: string,
|
||||
error: unknown,
|
||||
) => Promise<void> = reportHandlerLoadFailure,
|
||||
): Promise<string[]> {
|
||||
const failed: string[] = [];
|
||||
for (const { queue, load } of bound) {
|
||||
try {
|
||||
await load();
|
||||
} catch (error) {
|
||||
failed.push(queue);
|
||||
try {
|
||||
await report(queue, error);
|
||||
} catch (reportError) {
|
||||
// The report failing must not stop the sweep; say so on stderr.
|
||||
workerLog(
|
||||
"error",
|
||||
`[registry] reporting the load failure of ${queue} failed`,
|
||||
reportError,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
workerLog(
|
||||
failed.length === 0 ? "info" : "error",
|
||||
`[registry] preloaded ${bound.length - failed.length}/${bound.length} job handlers` +
|
||||
(failed.length === 0 ? "" : `; failed to load: ${failed.join(", ")}`),
|
||||
);
|
||||
return failed;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { reportJobsCutOffAtBoot } from "@/lib/jobs/job-observer";
|
||||
import { beginBootStep, reportBootSteps } from "@/lib/boot/boot-steps";
|
||||
import { assertSubsystemEnabled } from "@/lib/process-type";
|
||||
import { DATABASE_URL, workerLog } from "./reminder/shared";
|
||||
import type { BoundLoader } from "./registry/index";
|
||||
|
||||
/**
|
||||
* Give the event loop a turn around a module load, so requests and the
|
||||
@@ -76,8 +77,11 @@ export async function startReminderWorker() {
|
||||
"registry",
|
||||
() => import("./registry/index"),
|
||||
);
|
||||
const registeredQueues = await registry.registerAllJobGroups(boss, (group) =>
|
||||
beginBootStep(`register_${group.name}`),
|
||||
const bound: BoundLoader[] = [];
|
||||
const registeredQueues = await registry.registerAllJobGroups(
|
||||
boss,
|
||||
(group) => beginBootStep(`register_${group.name}`),
|
||||
(loader) => bound.push(loader),
|
||||
);
|
||||
workerLog(
|
||||
"info",
|
||||
@@ -100,6 +104,11 @@ export async function startReminderWorker() {
|
||||
await registry.runAllBootDiscovery();
|
||||
|
||||
await reportBootSteps({ boot_queue_count: registeredQueues.length });
|
||||
|
||||
// Every handler module is loaded once now, in the background, so one that
|
||||
// cannot load is reported at deploy time rather than on its queue's first
|
||||
// job. Not awaited: readiness does not wait on it, and it never throws.
|
||||
void registry.preloadHandlers(bound);
|
||||
return boss;
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,20 @@ async function signalRunFailed(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A queue's handler module that failed to load before any job asked for it
|
||||
* (the worker's boot preload, `preloadHandlers`). Reported the way a failed
|
||||
* run is, so the operator hears about it at deploy time on the line the
|
||||
* repeated-failure alert already reads, instead of on the queue's first job.
|
||||
*/
|
||||
export async function reportHandlerLoadFailure(
|
||||
queue: string,
|
||||
error: unknown,
|
||||
): Promise<void> {
|
||||
await reportWorkerError(queue, error, { phase: "handler_preload" });
|
||||
await signalRunFailed(queue, "handler_load_failed", error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a handler for `boss.work`. Resolves with the bounded, redacted
|
||||
* persistence representation on `ok: true`; reports and rethrows on
|
||||
|
||||
@@ -56,9 +56,14 @@ const inFlight = new Set<Promise<unknown>>();
|
||||
export function trackBackgroundTask(settled: Promise<unknown>): void {
|
||||
if (!TRACK_IN_FLIGHT) return;
|
||||
|
||||
inFlight.add(settled);
|
||||
void settled.finally(() => {
|
||||
inFlight.delete(settled);
|
||||
// A test double that stands in for the background work often returns
|
||||
// nothing at all. Filed as is, `undefined` would never leave the set and
|
||||
// `settleBackgroundTasks` would spin on it forever at full CPU. A real
|
||||
// promise passes through `Promise.resolve` as the same object.
|
||||
const handle = Promise.resolve(settled);
|
||||
inFlight.add(handle);
|
||||
void handle.finally(() => {
|
||||
inFlight.delete(handle);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -27,10 +27,7 @@ import {
|
||||
MODULE_LIST_VIEWS,
|
||||
MODULE_SORT_DIRS,
|
||||
} from "@/lib/settings/module-list-layout";
|
||||
import {
|
||||
moduleListLayoutPutSchema,
|
||||
previewFeaturesPatchSchema,
|
||||
} from "@/lib/validations/settings-prefs";
|
||||
import { moduleListLayoutPutSchema } from "@/lib/validations/settings-prefs";
|
||||
|
||||
const moduleListPrefsEntry = z
|
||||
.object({
|
||||
@@ -72,20 +69,6 @@ const moduleListLayoutSchema = z
|
||||
"Per-user presentation of the labs, illness and checkups lists. Display-only; a client that does not render a list ignores it.",
|
||||
});
|
||||
|
||||
const previewFeaturesSchema = z
|
||||
.object({
|
||||
topics: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"Topic overview pages above the single-metric Insights pages. Off by default.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "PreviewFeatures",
|
||||
description:
|
||||
"Per-user opt-in to preview features, one boolean per flag, every flag off by default.",
|
||||
});
|
||||
|
||||
// v1.16.11 — the one threshold read every dose-status consumer makes
|
||||
// (cards, table, take-all-due derivation). `lateMinutes` /
|
||||
// `missedMinutes` are the operator-level singleton; the low-stock
|
||||
@@ -970,57 +953,6 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/settings/preview-features": {
|
||||
get: {
|
||||
tags: ["Meta"],
|
||||
summary: "Get the preview-feature opt-ins",
|
||||
description: "Every flag, off unless the caller switched it on.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The opt-ins.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
previewFeaturesSchema,
|
||||
"PreviewFeaturesEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
patch: {
|
||||
tags: ["Meta"],
|
||||
summary: "Switch preview features on or off",
|
||||
description:
|
||||
"Sets the flags the body names and keeps the rest. An unknown flag or an empty body is refused with 422.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: previewFeaturesPatchSchema.meta({
|
||||
id: "PreviewFeaturesPatchBody",
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The opt-ins as stored.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
previewFeaturesSchema,
|
||||
"PreviewFeaturesSavedEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/settings/privacy-summary": {
|
||||
get: {
|
||||
tags: ["Meta"],
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DEFAULT_PREVIEW_FEATURES,
|
||||
resolvePreviewFeatures,
|
||||
} from "../preview-features";
|
||||
|
||||
describe("resolvePreviewFeatures", () => {
|
||||
it("is all off by default", () => {
|
||||
expect(DEFAULT_PREVIEW_FEATURES).toEqual({ topics: false });
|
||||
expect(resolvePreviewFeatures(null)).toEqual({ topics: false });
|
||||
});
|
||||
|
||||
it("reads a flag only on an explicit true", () => {
|
||||
expect(resolvePreviewFeatures({ topics: true })).toEqual({ topics: true });
|
||||
expect(resolvePreviewFeatures({ topics: "true" })).toEqual({
|
||||
topics: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("drops unknown keys", () => {
|
||||
expect(resolvePreviewFeatures({ topics: true, retired: true })).toEqual({
|
||||
topics: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* Per-user opt-in to preview features, persisted in `User.previewFeaturesJson`.
|
||||
*
|
||||
* One boolean per flag, every flag off by default. A preview feature is
|
||||
* something finished enough to try and not yet the default; switching one on
|
||||
* adds it beside what is there and never replaces an existing page.
|
||||
*
|
||||
* Flags:
|
||||
* - `topics`: topic overview pages above the single-metric Insights pages.
|
||||
* Stored, served by `/api/settings/preview-features` and carried by the
|
||||
* backup. The Settings > System switch is not listed until the topic pages
|
||||
* ship, because nothing reads the flag yet.
|
||||
*
|
||||
* Unknown keys in a stored blob are dropped on read, so a flag retired later
|
||||
* disappears cleanly and a malformed blob reads as everything off.
|
||||
*/
|
||||
|
||||
export const PREVIEW_FEATURE_KEYS = ["topics"] as const;
|
||||
export type PreviewFeatureKey = (typeof PREVIEW_FEATURE_KEYS)[number];
|
||||
|
||||
export type PreviewFeatures = Record<PreviewFeatureKey, boolean>;
|
||||
|
||||
export const DEFAULT_PREVIEW_FEATURES: PreviewFeatures = { topics: false };
|
||||
|
||||
export function resolvePreviewFeatures(raw: unknown): PreviewFeatures {
|
||||
const out: PreviewFeatures = { ...DEFAULT_PREVIEW_FEATURES };
|
||||
if (raw === null || raw === undefined || typeof raw !== "object") return out;
|
||||
const blob = raw as Record<string, unknown>;
|
||||
for (const key of PREVIEW_FEATURE_KEYS) {
|
||||
if (blob[key] === true) out[key] = true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -114,10 +114,6 @@ export const SETTINGS_SECTION_SLUGS = [
|
||||
// pieces into one coherent privacy pane. Sits next to `advanced` (the
|
||||
// destructive-action home) and before `about`.
|
||||
"privacy",
|
||||
// v1.44 — "Preview features" (per-user opt-ins, Settings > System) is not
|
||||
// listed until the first preview it switches, the topic pages, ships: a
|
||||
// switch that changes nothing is a promise the page cannot keep. The stored
|
||||
// flag, `/api/settings/preview-features` and its backup round-trip stay.
|
||||
"about",
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -28,12 +28,6 @@ const MEASUREMENT_SELECT = {
|
||||
ingestToken: APPLE_HEALTH_MIRROR_SELECT.ingestToken,
|
||||
} satisfies Prisma.MeasurementSelect;
|
||||
|
||||
const MEASUREMENT_MIRROR_SELECT = {
|
||||
id: true,
|
||||
source: true,
|
||||
ingestToken: { select: { mirrorToAppleHealth: true, mirrorChangedAt: true } },
|
||||
} satisfies Prisma.MeasurementSelect;
|
||||
|
||||
const MOOD_SELECT = {
|
||||
id: true,
|
||||
date: true,
|
||||
@@ -81,8 +75,15 @@ export type CycleDayRow = Prisma.CycleDayLogGetPayload<{
|
||||
include: typeof dayLogSymptomInclude;
|
||||
}>;
|
||||
export type CycleRow = Prisma.MenstrualCycleGetPayload<object>;
|
||||
/** A reading the mirror walk re-delivers, with its token's current setting. */
|
||||
export type MeasurementMirrorRow = Prisma.MeasurementGetPayload<{
|
||||
select: typeof MEASUREMENT_MIRROR_SELECT;
|
||||
select: {
|
||||
id: true;
|
||||
source: true;
|
||||
ingestToken: {
|
||||
select: { mirrorToAppleHealth: true; mirrorChangedAt: true };
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
/**
|
||||
@@ -109,41 +110,94 @@ export interface DomainRows {
|
||||
measurementMirror: MeasurementMirrorRow[];
|
||||
}
|
||||
|
||||
const MEASUREMENT_MIRROR_ROW_SELECT = {
|
||||
id: true,
|
||||
source: true,
|
||||
} satisfies Prisma.MeasurementSelect;
|
||||
|
||||
/**
|
||||
* The live readings of every token whose Apple Health setting changed past
|
||||
* the watermark, in `(token.mirrorChangedAt, measurement.id)` order. Switching
|
||||
* the setting writes the token row only, so the readings keep their
|
||||
* `updatedAt` (nothing keyed on it recomputes) and arrive through this walk
|
||||
* instead of the measurement one.
|
||||
*
|
||||
* The walk starts from the tokens, not the readings. Ordering readings by a
|
||||
* column of the joined token row cannot use any index on `measurements`, so
|
||||
* the single-query form sorted every row of every token that had ever been
|
||||
* switched, on every poll, even when nothing had changed since the client's
|
||||
* watermark. Reading the switched tokens first is a lookup on a handful of
|
||||
* rows; nearly every poll finds none and ends there. Otherwise the readings
|
||||
* are walked one change stamp at a time, by `(ingest_token_id, id)`, which is
|
||||
* the order the index already holds. Tokens that share a stamp are walked
|
||||
* together so their rows interleave by id exactly as the one-query form
|
||||
* ordered them.
|
||||
*
|
||||
* The token lookup filters on the token's owner. That is exact because a
|
||||
* reading only ever links to a token of its own account: the link is written
|
||||
* for a narrow `measurements:write` Bearer, which never acts on another record
|
||||
* (`requireRecordAuth` refuses the carrier), and a restore keeps the link only
|
||||
* for a token the restoring account holds.
|
||||
*/
|
||||
function readMeasurementMirrorRows(
|
||||
async function readMeasurementMirrorRows(
|
||||
userId: string,
|
||||
wm: DomainWatermark | undefined,
|
||||
limit: number,
|
||||
): Promise<MeasurementMirrorRow[]> {
|
||||
const at = wm ? new Date(wm.updatedAtMs) : null;
|
||||
return prisma.measurement.findMany({
|
||||
const tokens = await prisma.apiToken.findMany({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
ingestToken: { mirrorChangedAt: { not: null } },
|
||||
...(at &&
|
||||
wm && {
|
||||
OR: [
|
||||
{ ingestToken: { mirrorChangedAt: { gt: at } } },
|
||||
{
|
||||
AND: [
|
||||
{ ingestToken: { mirrorChangedAt: at } },
|
||||
{ id: { gt: wm.id } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
mirrorChangedAt: at ? { gte: at } : { not: null },
|
||||
},
|
||||
orderBy: [{ ingestToken: { mirrorChangedAt: "asc" } }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
select: MEASUREMENT_MIRROR_SELECT,
|
||||
orderBy: [{ mirrorChangedAt: "asc" }, { id: "asc" }],
|
||||
select: { id: true, mirrorToAppleHealth: true, mirrorChangedAt: true },
|
||||
});
|
||||
if (tokens.length === 0) return [];
|
||||
|
||||
// One group per change stamp, in stamp order.
|
||||
const groups: { stampMs: number; tokens: typeof tokens }[] = [];
|
||||
for (const token of tokens) {
|
||||
const stampMs = token.mirrorChangedAt!.getTime();
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && last.stampMs === stampMs) last.tokens.push(token);
|
||||
else groups.push({ stampMs, tokens: [token] });
|
||||
}
|
||||
|
||||
const out: MeasurementMirrorRow[] = [];
|
||||
for (const group of groups) {
|
||||
const want = limit + 1 - out.length;
|
||||
if (want <= 0) break;
|
||||
// The watermark's own stamp resumes after the last row delivered; every
|
||||
// later stamp starts from its first row.
|
||||
const resume = wm && group.stampMs === wm.updatedAtMs ? wm.id : null;
|
||||
const byId = new Map(group.tokens.map((t) => [t.id, t]));
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
ingestTokenId:
|
||||
group.tokens.length === 1
|
||||
? group.tokens[0].id
|
||||
: { in: group.tokens.map((t) => t.id) },
|
||||
userId,
|
||||
deletedAt: null,
|
||||
...(resume !== null && { id: { gt: resume } }),
|
||||
},
|
||||
orderBy: { id: "asc" },
|
||||
take: want,
|
||||
select: { ...MEASUREMENT_MIRROR_ROW_SELECT, ingestTokenId: true },
|
||||
});
|
||||
for (const { ingestTokenId, ...row } of rows) {
|
||||
const token = byId.get(ingestTokenId!)!;
|
||||
out.push({
|
||||
...row,
|
||||
ingestToken: {
|
||||
mirrorToAppleHealth: token.mirrorToAppleHealth,
|
||||
mirrorChangedAt: token.mirrorChangedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
* are read in the user's own calendar.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { runDetached } from "@/lib/logging/detached";
|
||||
import { trackBackgroundTask } from "@/lib/logging/background-tasks";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
COURSES_COMPLIANCE_SELECT,
|
||||
@@ -102,7 +104,7 @@ export async function buildTargetsResponse(user: AuthedUser) {
|
||||
queryNow.getTime() - 5 * 365 * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
|
||||
void ensureUserMoodRollupsFresh(userId);
|
||||
trackBackgroundTask(runDetached(() => ensureUserMoodRollupsFresh(userId)));
|
||||
|
||||
// All independent reads stay in one bounded fan-out. The medication event
|
||||
// read is the sole dependent query and remains chained to its medication
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Request schemas for the per-user Settings preferences that are not part of
|
||||
* the account payload: the list presentation of the labs, illness and
|
||||
* checkups lists, and the preview-feature opt-ins. Shared by the routes and
|
||||
* the OpenAPI registry so the wire contract has one source.
|
||||
* checkups lists. Shared by the route and the OpenAPI registry so the wire
|
||||
* contract has one source.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
@@ -26,12 +26,3 @@ export const moduleListLayoutPutSchema = z
|
||||
sortDir: z.enum(MODULE_SORT_DIRS).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const previewFeaturesPatchSchema = z
|
||||
.object({
|
||||
topics: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine((body) => Object.keys(body).length > 0, {
|
||||
message: "Name at least one preview feature.",
|
||||
});
|
||||
@@ -2660,7 +2660,6 @@
|
||||
"src/lib/search/__tests__/match.test.ts": 0,
|
||||
"src/lib/search/__tests__/search-records.test.ts": 0,
|
||||
"src/lib/settings/__tests__/module-list-layout.test.ts": 0,
|
||||
"src/lib/settings/__tests__/preview-features.test.ts": 0,
|
||||
"src/lib/sharing/__tests__/account-access-parse.test.ts": 0,
|
||||
"src/lib/sharing/__tests__/account-access.test.ts": 0,
|
||||
"src/lib/sharing/__tests__/account-label.test.ts": 0,
|
||||
|
||||
@@ -124,9 +124,6 @@
|
||||
"moduleListLayoutJson": {
|
||||
"roundTrip": "rt-User-moduleListLayoutJson"
|
||||
},
|
||||
"previewFeaturesJson": {
|
||||
"roundTrip": "rt-User-previewFeaturesJson"
|
||||
},
|
||||
"activityGoalsJson": {
|
||||
"stepsPerMonth": 250000,
|
||||
"workoutsPerWeek": 4
|
||||
@@ -2566,9 +2563,6 @@
|
||||
"moduleListLayoutJson": {
|
||||
"roundTrip": "rt-User-moduleListLayoutJson"
|
||||
},
|
||||
"previewFeaturesJson": {
|
||||
"roundTrip": "rt-User-previewFeaturesJson"
|
||||
},
|
||||
"activityGoalsJson": {
|
||||
"stepsPerMonth": 250000,
|
||||
"workoutsPerWeek": 4
|
||||
|
||||
@@ -63,9 +63,6 @@
|
||||
"moduleListLayoutJson": {
|
||||
"roundTrip": "rt-User-moduleListLayoutJson"
|
||||
},
|
||||
"previewFeaturesJson": {
|
||||
"roundTrip": "rt-User-previewFeaturesJson"
|
||||
},
|
||||
"activityGoalsJson": {
|
||||
"stepsPerMonth": 250000,
|
||||
"workoutsPerWeek": 4
|
||||
@@ -1725,9 +1722,6 @@
|
||||
"moduleListLayoutJson": {
|
||||
"roundTrip": "rt-User-moduleListLayoutJson"
|
||||
},
|
||||
"previewFeaturesJson": {
|
||||
"roundTrip": "rt-User-previewFeaturesJson"
|
||||
},
|
||||
"activityGoalsJson": {
|
||||
"stepsPerMonth": 250000,
|
||||
"workoutsPerWeek": 4
|
||||
|
||||
@@ -332,7 +332,7 @@
|
||||
"symptomDefinitions": 2,
|
||||
"lifeEvents": 3
|
||||
},
|
||||
"accountSettingsApplied": 62,
|
||||
"accountSettingsApplied": 61,
|
||||
"skipped": {
|
||||
"catalogueKeys": [],
|
||||
"links": 0
|
||||
|
||||
@@ -332,7 +332,7 @@
|
||||
"symptomDefinitions": 0,
|
||||
"lifeEvents": 0
|
||||
},
|
||||
"accountSettingsApplied": 62,
|
||||
"accountSettingsApplied": 61,
|
||||
"skipped": {
|
||||
"catalogueKeys": [],
|
||||
"links": 0
|
||||
@@ -2759,9 +2759,6 @@
|
||||
"moduleListLayoutJson": {
|
||||
"roundTrip": "rt-User-moduleListLayoutJson"
|
||||
},
|
||||
"previewFeaturesJson": {
|
||||
"roundTrip": "rt-User-previewFeaturesJson"
|
||||
},
|
||||
"activityGoalsJson": {
|
||||
"stepsPerMonth": 250000,
|
||||
"workoutsPerWeek": 4
|
||||
@@ -3029,9 +3026,6 @@
|
||||
"moduleListLayoutJson": {
|
||||
"roundTrip": "rt-User-moduleListLayoutJson"
|
||||
},
|
||||
"previewFeaturesJson": {
|
||||
"roundTrip": "rt-User-previewFeaturesJson"
|
||||
},
|
||||
"activityGoalsJson": {
|
||||
"stepsPerMonth": 250000,
|
||||
"workoutsPerWeek": 4
|
||||
|
||||
@@ -378,7 +378,7 @@
|
||||
"symptomDefinitions": 0,
|
||||
"lifeEvents": 0
|
||||
},
|
||||
"accountSettingsApplied": 62,
|
||||
"accountSettingsApplied": 61,
|
||||
"skipped": {
|
||||
"catalogueKeys": [
|
||||
{
|
||||
@@ -2254,9 +2254,6 @@
|
||||
"moduleListLayoutJson": {
|
||||
"roundTrip": "rt-User-moduleListLayoutJson"
|
||||
},
|
||||
"previewFeaturesJson": {
|
||||
"roundTrip": "rt-User-previewFeaturesJson"
|
||||
},
|
||||
"activityGoalsJson": {
|
||||
"stepsPerMonth": 250000,
|
||||
"workoutsPerWeek": 4
|
||||
@@ -2463,9 +2460,6 @@
|
||||
"moduleListLayoutJson": {
|
||||
"roundTrip": "rt-User-moduleListLayoutJson"
|
||||
},
|
||||
"previewFeaturesJson": {
|
||||
"roundTrip": "rt-User-previewFeaturesJson"
|
||||
},
|
||||
"activityGoalsJson": {
|
||||
"stepsPerMonth": 250000,
|
||||
"workoutsPerWeek": 4
|
||||
|
||||
@@ -894,7 +894,6 @@
|
||||
"documentsLayoutJson": null,
|
||||
"moodTagLayoutJson": null,
|
||||
"moduleListLayoutJson": null,
|
||||
"previewFeaturesJson": null,
|
||||
"activityGoalsJson": null,
|
||||
"glucoseUnit": null,
|
||||
"hasDiabetes": false,
|
||||
|
||||
@@ -1027,7 +1027,6 @@
|
||||
"documentsLayoutJson": null,
|
||||
"moodTagLayoutJson": null,
|
||||
"moduleListLayoutJson": null,
|
||||
"previewFeaturesJson": null,
|
||||
"activityGoalsJson": null,
|
||||
"glucoseUnit": null,
|
||||
"hasDiabetes": false,
|
||||
@@ -1247,7 +1246,6 @@
|
||||
"moodTagLayoutJson": null,
|
||||
"documentsLayoutJson": null,
|
||||
"moduleListLayoutJson": null,
|
||||
"previewFeaturesJson": null,
|
||||
"activityGoalsJson": null,
|
||||
"reportSelectionJson": null,
|
||||
"globalExcludedInjectionSites": [],
|
||||
|
||||
@@ -2374,9 +2374,6 @@
|
||||
"moduleListLayoutJson": {
|
||||
"roundTrip": "rt-User-moduleListLayoutJson"
|
||||
},
|
||||
"previewFeaturesJson": {
|
||||
"roundTrip": "rt-User-previewFeaturesJson"
|
||||
},
|
||||
"activityGoalsJson": {
|
||||
"stepsPerMonth": 250000,
|
||||
"workoutsPerWeek": 4
|
||||
|
||||
@@ -281,9 +281,15 @@ async function seed(): Promise<void> {
|
||||
}),
|
||||
});
|
||||
|
||||
// Both rollup tiers are minted here, the way a running account has them.
|
||||
// Seeded rows without their rollups make the first read start a full
|
||||
// recompute in the background, and that is a warm-up cost, not the read's.
|
||||
const { recomputeUserRollups } =
|
||||
await import("@/lib/rollups/measurement-rollups");
|
||||
await recomputeUserRollups(USER_ID);
|
||||
const { recomputeUserMoodRollups } =
|
||||
await import("@/lib/rollups/mood-rollups");
|
||||
await recomputeUserMoodRollups(USER_ID);
|
||||
|
||||
const session = await db.session.create({
|
||||
data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
/**
|
||||
* The two per-user Settings preferences that live outside the account
|
||||
* payload, end to end against Postgres:
|
||||
*
|
||||
* - `/api/settings/module-list-layout`: defaults without a write, one list
|
||||
* changed per PUT with preserve-when-absent fields and untouched neighbours,
|
||||
* the multi-issue 422 for unknown values, and the column carrying only what
|
||||
* was chosen.
|
||||
* - `/api/settings/preview-features`: all off by default, PATCH keeps the
|
||||
* flags it does not name, an unknown flag or an empty body is refused.
|
||||
* The per-user list layout, a Settings preference that lives outside the
|
||||
* account payload, end to end against Postgres:
|
||||
* `/api/settings/module-list-layout` answers defaults without a write,
|
||||
* changes one list per PUT with preserve-when-absent fields and untouched
|
||||
* neighbours, refuses unknown values with the multi-issue 422, and stores
|
||||
* only what was chosen.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -173,51 +170,8 @@ describe("/api/settings/module-list-layout — integration", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("/api/settings/preview-features — integration", () => {
|
||||
const URL = "http://localhost/api/settings/preview-features";
|
||||
|
||||
it("is all off by default and keeps a flag once switched on", async () => {
|
||||
const { userId } = await seedUser();
|
||||
const { GET, PATCH } =
|
||||
await import("@/app/api/settings/preview-features/route");
|
||||
|
||||
const before = await (GET as Handler)(new Request(URL));
|
||||
expect(((await before.json()) as { data: unknown }).data).toEqual({
|
||||
topics: false,
|
||||
});
|
||||
|
||||
const on = await (PATCH as Handler)(json("PATCH", URL, { topics: true }));
|
||||
expect(on.status).toBe(200);
|
||||
expect(((await on.json()) as { data: unknown }).data).toEqual({
|
||||
topics: true,
|
||||
});
|
||||
|
||||
const after = await (GET as Handler)(new Request(URL));
|
||||
expect(((await after.json()) as { data: unknown }).data).toEqual({
|
||||
topics: true,
|
||||
});
|
||||
const row = await getPrismaClient().user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { previewFeaturesJson: true },
|
||||
});
|
||||
expect(row?.previewFeaturesJson).toEqual({ topics: true });
|
||||
});
|
||||
|
||||
it("refuses an unknown flag and an empty body", async () => {
|
||||
await seedUser();
|
||||
const { PATCH } = await import("@/app/api/settings/preview-features/route");
|
||||
const unknown = await (PATCH as Handler)(
|
||||
json("PATCH", URL, { topics: true, darkMagic: true }),
|
||||
);
|
||||
expect(unknown.status).toBe(422);
|
||||
const empty = await (PATCH as Handler)(json("PATCH", URL, {}));
|
||||
expect(empty.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings presentation follows the person, also while acting for a record", () => {
|
||||
const LAYOUT = "http://localhost/api/settings/module-list-layout";
|
||||
const PREVIEW = "http://localhost/api/settings/preview-features";
|
||||
|
||||
async function actingForAnotherRecord() {
|
||||
const prisma = getPrismaClient();
|
||||
@@ -251,10 +205,9 @@ describe("settings presentation follows the person, also while acting for a reco
|
||||
return { ownerId: owner.id, actorId: actor.id };
|
||||
}
|
||||
|
||||
it("reads and saves the actor's own layout and preview switch", async () => {
|
||||
it("reads and saves the actor's own layout", async () => {
|
||||
const { ownerId, actorId } = await actingForAnotherRecord();
|
||||
const layout = await import("@/app/api/settings/module-list-layout/route");
|
||||
const preview = await import("@/app/api/settings/preview-features/route");
|
||||
|
||||
expect((await (layout.GET as Handler)(new Request(LAYOUT))).status).toBe(
|
||||
200,
|
||||
@@ -263,20 +216,12 @@ describe("settings presentation follows the person, also while acting for a reco
|
||||
json("PUT", LAYOUT, { version: 1, list: "labs", view: "cards" }),
|
||||
);
|
||||
expect(put.status).toBe(200);
|
||||
expect((await (preview.GET as Handler)(new Request(PREVIEW))).status).toBe(
|
||||
200,
|
||||
);
|
||||
const patch = await (preview.PATCH as Handler)(
|
||||
json("PATCH", PREVIEW, { topics: true }),
|
||||
);
|
||||
expect(patch.status).toBe(200);
|
||||
|
||||
const rows = await getPrismaClient().user.findMany({
|
||||
where: { id: { in: [ownerId, actorId] } },
|
||||
select: {
|
||||
id: true,
|
||||
moduleListLayoutJson: true,
|
||||
previewFeaturesJson: true,
|
||||
},
|
||||
});
|
||||
const actor = rows.find((r) => r.id === actorId)!;
|
||||
@@ -284,9 +229,7 @@ describe("settings presentation follows the person, also while acting for a reco
|
||||
expect(actor.moduleListLayoutJson).toMatchObject({
|
||||
labs: { view: "cards" },
|
||||
});
|
||||
expect(actor.previewFeaturesJson).toEqual({ topics: true });
|
||||
expect(owner.moduleListLayoutJson).toBeNull();
|
||||
expect(owner.previewFeaturesJson).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -69,7 +69,6 @@ import * as medicationLayout from "@/app/api/medications/layout/route";
|
||||
import * as moodTagLayout from "@/app/api/mood/tags/layout/route";
|
||||
import * as documentsLayout from "@/app/api/documents/inbound/layout/route";
|
||||
import * as moduleListLayout from "@/app/api/settings/module-list-layout/route";
|
||||
import * as previewFeatures from "@/app/api/settings/preview-features/route";
|
||||
import * as activityGoals from "@/app/api/activity/goals/route";
|
||||
import * as healthKit from "@/app/api/integrations/healthkit/route";
|
||||
import { POST as exportHealthRecord } from "@/app/api/export/health-record/route";
|
||||
@@ -888,21 +887,6 @@ function entries(user: Pick<User, "id">): Entry[] {
|
||||
},
|
||||
expected: ["list", "alphaAsc"],
|
||||
},
|
||||
{
|
||||
columns: ["previewFeaturesJson"],
|
||||
write: {
|
||||
handler: previewFeatures.PATCH,
|
||||
method: "PATCH",
|
||||
route: "/api/settings/preview-features",
|
||||
body: { topics: true },
|
||||
},
|
||||
read: get(
|
||||
previewFeatures.GET,
|
||||
"/api/settings/preview-features",
|
||||
"topics",
|
||||
),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
columns: ["activityGoalsJson"],
|
||||
write: {
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* The `measurementMirror` walk of `/api/sync/changes`, against Postgres.
|
||||
*
|
||||
* The walk reads the switched tokens first and then each change stamp's
|
||||
* readings by `(ingest_token_id, id)`. The page sequence a client sees must be
|
||||
* exactly the one the earlier single query produced, which ordered every
|
||||
* token-linked reading by `(token.mirrorChangedAt, measurement.id)` and
|
||||
* resumed past `(stamp, id)`. That query is kept below as the reference, and
|
||||
* every page of every walk is compared against it: tied stamps across tokens,
|
||||
* a watermark inside a tie, soft-deleted rows, another account's token, a
|
||||
* token never switched, readings with no token, and page sizes from one row
|
||||
* up to the whole set.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { readDomainRows } from "@/lib/sync/changes/read-rows";
|
||||
import type { SyncCursor } from "@/lib/sync/cursor";
|
||||
|
||||
import { makeMeasurement, makeToken, makeUser } from "../kit/factories";
|
||||
import { countStatements } from "./query-counter";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
interface Wm {
|
||||
updatedAtMs: number;
|
||||
id: string;
|
||||
}
|
||||
|
||||
/** The single-query walk this replaced, verbatim in its semantics. */
|
||||
async function referenceWalk(
|
||||
userId: string,
|
||||
wm: Wm | undefined,
|
||||
limit: number,
|
||||
) {
|
||||
const at = wm ? new Date(wm.updatedAtMs) : null;
|
||||
return prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
ingestToken: { mirrorChangedAt: { not: null } },
|
||||
...(at &&
|
||||
wm && {
|
||||
OR: [
|
||||
{ ingestToken: { mirrorChangedAt: { gt: at } } },
|
||||
{
|
||||
AND: [
|
||||
{ ingestToken: { mirrorChangedAt: at } },
|
||||
{ id: { gt: wm.id } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
orderBy: [{ ingestToken: { mirrorChangedAt: "asc" } }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
select: {
|
||||
id: true,
|
||||
source: true,
|
||||
ingestToken: {
|
||||
select: { mirrorToAppleHealth: true, mirrorChangedAt: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function walk(userId: string, wm: Wm | undefined, limit: number) {
|
||||
const cursor = { v: 2, measurementMirror: wm } as unknown as SyncCursor;
|
||||
const rows = await readDomainRows(userId, cursor, limit, false);
|
||||
return rows.measurementMirror;
|
||||
}
|
||||
|
||||
/** Page through both walks from `start`, asserting each page is identical. */
|
||||
async function pageThrough(userId: string, limit: number, start?: Wm) {
|
||||
let wm = start;
|
||||
const seen: string[] = [];
|
||||
for (let guard = 0; guard < 200; guard += 1) {
|
||||
const [actual, expected] = await Promise.all([
|
||||
walk(userId, wm, limit),
|
||||
referenceWalk(userId, wm, limit),
|
||||
]);
|
||||
expect(actual, `limit ${limit}, page ${guard}`).toEqual(expected);
|
||||
const page = actual.slice(0, limit);
|
||||
seen.push(...page.map((r) => r.id));
|
||||
if (actual.length <= limit) return { seen, wm };
|
||||
const last = page[page.length - 1];
|
||||
wm = {
|
||||
updatedAtMs: last.ingestToken!.mirrorChangedAt!.getTime(),
|
||||
id: last.id,
|
||||
};
|
||||
}
|
||||
throw new Error("walk did not terminate");
|
||||
}
|
||||
|
||||
let userId: string;
|
||||
let otherId: string;
|
||||
const T0 = new Date("2026-10-01T08:00:00.000Z");
|
||||
const T1 = new Date("2026-10-02T08:00:00.000Z");
|
||||
const T2 = new Date("2026-10-03T08:00:00.000Z");
|
||||
|
||||
async function rowsFor(
|
||||
owner: { id: string },
|
||||
tokenId: string | null,
|
||||
n: number,
|
||||
base: number,
|
||||
deleted: number[] = [],
|
||||
) {
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
await makeMeasurement(owner, {
|
||||
type: "WEIGHT",
|
||||
unit: "kg",
|
||||
value: 80 + i / 10,
|
||||
source: "EXTERNAL",
|
||||
measuredAt: new Date(Date.UTC(2026, 8, 1, 0, base + i)),
|
||||
ingestTokenId: tokenId,
|
||||
deletedAt: deleted.includes(i) ? new Date() : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function stamp(tokenId: string, at: Date, on = true) {
|
||||
await prisma.apiToken.update({
|
||||
where: { id: tokenId },
|
||||
data: { mirrorChangedAt: at, mirrorToAppleHealth: on },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
const user = await makeUser({ timezone: "UTC" });
|
||||
const other = await makeUser({ timezone: "UTC" });
|
||||
userId = user.id;
|
||||
otherId = other.id;
|
||||
const [a, b, c, d, never, othersOwn] = await Promise.all([
|
||||
makeToken(user, { name: "A" }),
|
||||
makeToken(user, { name: "B" }),
|
||||
makeToken(user, { name: "C" }),
|
||||
makeToken(user, { name: "D" }),
|
||||
makeToken(user, { name: "never" }),
|
||||
makeToken(other, { name: "others-own" }),
|
||||
]);
|
||||
await rowsFor(user, a.tokenId, 7, 0, [3]);
|
||||
await rowsFor(user, b.tokenId, 5, 100, [0]);
|
||||
await rowsFor(user, c.tokenId, 3, 200);
|
||||
await rowsFor(user, never.tokenId, 3, 300);
|
||||
await rowsFor(user, d.tokenId, 2, 400);
|
||||
await rowsFor(other, othersOwn.tokenId, 3, 600);
|
||||
await rowsFor(user, null, 4, 700);
|
||||
// A and B share a stamp, so their rows interleave by id.
|
||||
await stamp(a.tokenId, T0);
|
||||
await stamp(b.tokenId, T0, false);
|
||||
await stamp(c.tokenId, T1);
|
||||
await stamp(d.tokenId, T2);
|
||||
await stamp(othersOwn.tokenId, T1);
|
||||
});
|
||||
|
||||
describe("the measurementMirror walk", () => {
|
||||
it.each([1, 2, 3, 5, 8, 100])(
|
||||
"pages exactly as the single query did, %i rows a page",
|
||||
async (limit) => {
|
||||
const { seen } = await pageThrough(userId, limit);
|
||||
// 6 + 4 live rows on the tied stamp, 3 on C, 2 on D. Nothing from the
|
||||
// never-switched token, nothing of the other account's, nothing
|
||||
// deleted, nothing without a token.
|
||||
expect(seen).toHaveLength(15);
|
||||
expect(new Set(seen).size).toBe(15);
|
||||
},
|
||||
);
|
||||
|
||||
it("resumes inside a tied stamp, and re-delivers a token switched again", async () => {
|
||||
const all = await walk(userId, undefined, 100);
|
||||
const tied = all.filter(
|
||||
(r) => r.ingestToken!.mirrorChangedAt!.getTime() === T0.getTime(),
|
||||
);
|
||||
// Both tied tokens' rows interleave by id, so a watermark three rows into
|
||||
// the tie leaves rows of both still to come.
|
||||
await pageThrough(userId, 2, { updatedAtMs: T0.getTime(), id: tied[2].id });
|
||||
|
||||
const last = all[all.length - 1];
|
||||
const caughtUp: Wm = {
|
||||
updatedAtMs: last.ingestToken!.mirrorChangedAt!.getTime(),
|
||||
id: last.id,
|
||||
};
|
||||
expect(await walk(userId, caughtUp, 100)).toEqual([]);
|
||||
|
||||
const c = await prisma.apiToken.findFirstOrThrow({
|
||||
where: { userId, name: "C" },
|
||||
});
|
||||
await stamp(c.id, new Date("2026-10-04T08:00:00.000Z"), false);
|
||||
const { seen } = await pageThrough(userId, 2, caughtUp);
|
||||
expect(seen).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("answers a poll with nothing switched past its watermark from the token table alone", async () => {
|
||||
const wm = { updatedAtMs: T2.getTime() + 1, id: "" };
|
||||
const measured = await countStatements(() => walk(userId, wm, 100));
|
||||
expect(measured.result).toEqual([]);
|
||||
// The walk's reading-side shape: id, source and the token link.
|
||||
const readingWalks = measured.statements.filter((s) =>
|
||||
s.startsWith(
|
||||
'SELECT "public"."measurements"."id", "public"."measurements"."source"::text, "public"."measurements"."ingest_token_id"',
|
||||
),
|
||||
);
|
||||
expect(readingWalks).toEqual([]);
|
||||
const tokenLookups = measured.statements.filter((s) =>
|
||||
s.includes('"public"."api_tokens"."mirror_changed_at" FROM'),
|
||||
);
|
||||
expect(tokenLookups).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("leaves the other account's walk to the other account", async () => {
|
||||
const { seen } = await pageThrough(otherId, 2);
|
||||
expect(seen).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user