mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
Merge remote-tracking branch 'origin/release/v1.46.0' into feat/v146-ui
# Conflicts: # src/__tests__/baselines/source-function-lines.json # src/__tests__/baselines/version-stamp-comments.json
This commit is contained in:
commit
879473305c
115 files changed
+5037
-599
No files matched your search
@@ -36,6 +36,8 @@ export interface IssueTokenOptions {
|
||||
* issued and the connector token list can exclude the transient access rows.
|
||||
*/
|
||||
mcpConnectionId?: string;
|
||||
/** A measurement token's Apple Health write-back (#1272). Off by default. */
|
||||
mirrorToAppleHealth?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,6 +66,9 @@ export async function issueApiToken(
|
||||
...(opts.mcpConnectionId
|
||||
? { mcpConnectionId: opts.mcpConnectionId }
|
||||
: {}),
|
||||
...(opts.mirrorToAppleHealth === true
|
||||
? { mirrorToAppleHealth: true }
|
||||
: {}),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
@@ -66,6 +66,8 @@ export interface MeasurementInsertRow {
|
||||
sleepStage: string | null;
|
||||
rhythmClassification: string | null;
|
||||
deviceType: string | null;
|
||||
/** The token an EXTERNAL row came from (#1272); null when absent. */
|
||||
ingestTokenId?: string | null;
|
||||
syncVersion: number;
|
||||
deletedAt: Date | null;
|
||||
createdAt?: Date;
|
||||
@@ -98,6 +100,7 @@ const COLUMNS: { [K in keyof MeasurementInsertRow]-?: [string, Kind] } = {
|
||||
sleepStage: ["sleep_stage", "enum:sleep_stage"],
|
||||
rhythmClassification: ["rhythm_classification", "enum:rhythm_classification"],
|
||||
deviceType: ["device_type", "text"],
|
||||
ingestTokenId: ["ingest_token_id", "text"],
|
||||
syncVersion: ["sync_version", "int4"],
|
||||
deletedAt: ["deleted_at", "timestamp"],
|
||||
createdAt: ["created_at", "timestamp"],
|
||||
|
||||
@@ -41,6 +41,9 @@ export const measurementSchema = z
|
||||
sleepStage: z.enum(SleepStage).nullable().optional(),
|
||||
rhythmClassification: z.enum(RhythmClassification).nullable().optional(),
|
||||
deviceType: z.string().nullable().optional(),
|
||||
// The token that carried an EXTERNAL reading (#1272). Kept on restore
|
||||
// only when the restoring account still holds that token.
|
||||
ingestTokenId: z.string().max(64).nullable().optional(),
|
||||
syncVersion: z.number().int().optional(),
|
||||
deletedAt: isoDateTime.nullable().optional(),
|
||||
createdAt: isoDateTime.optional(),
|
||||
|
||||
@@ -42,6 +42,7 @@ const MEASUREMENT_BACKUP_SELECT = {
|
||||
sleepStage: true,
|
||||
rhythmClassification: true,
|
||||
deviceType: true,
|
||||
ingestTokenId: true,
|
||||
syncVersion: true,
|
||||
deletedAt: true,
|
||||
createdAt: true,
|
||||
@@ -86,6 +87,7 @@ function serialiseMeasurement(
|
||||
sleepStage: measurement.sleepStage,
|
||||
rhythmClassification: measurement.rhythmClassification,
|
||||
deviceType: measurement.deviceType,
|
||||
ingestTokenId: measurement.ingestTokenId,
|
||||
syncVersion: measurement.syncVersion,
|
||||
deletedAt: measurement.deletedAt?.toISOString() ?? null,
|
||||
createdAt: measurement.createdAt.toISOString(),
|
||||
|
||||
@@ -156,7 +156,10 @@ export async function clearMeasurementRollups(
|
||||
await ctx.tx.measurementRollup.deleteMany({ where: { userId: ctx.ownerId } });
|
||||
}
|
||||
|
||||
function toRestoredMeasurementData(measurement: BackupMeasurement) {
|
||||
function toRestoredMeasurementData(
|
||||
measurement: BackupMeasurement,
|
||||
ownTokenIds: ReadonlySet<string>,
|
||||
) {
|
||||
return {
|
||||
type: isLegacyRmssdRow(measurement)
|
||||
? ("HRV_RMSSD" as const)
|
||||
@@ -204,6 +207,13 @@ function toRestoredMeasurementData(measurement: BackupMeasurement) {
|
||||
sleepStage: (measurement.sleepStage ?? null) as never,
|
||||
rhythmClassification: (measurement.rhythmClassification ?? null) as never,
|
||||
deviceType: measurement.deviceType ?? null,
|
||||
// The token link (#1272) survives only onto the account that still holds
|
||||
// the token: a backup restored elsewhere, or after the token was deleted,
|
||||
// would point at a row that is not there or not this account's.
|
||||
ingestTokenId:
|
||||
measurement.ingestTokenId && ownTokenIds.has(measurement.ingestTokenId)
|
||||
? measurement.ingestTokenId
|
||||
: null,
|
||||
syncVersion: measurement.syncVersion ?? 1,
|
||||
deletedAt: measurement.deletedAt ? new Date(measurement.deletedAt) : null,
|
||||
...(measurement.createdAt
|
||||
@@ -343,8 +353,9 @@ async function reconcileWithoutId(
|
||||
tx: Prisma.TransactionClient,
|
||||
ownerId: string,
|
||||
measurement: BackupMeasurement,
|
||||
ownTokenIds: ReadonlySet<string>,
|
||||
): Promise<void> {
|
||||
const restoredData = toRestoredMeasurementData(measurement);
|
||||
const restoredData = toRestoredMeasurementData(measurement, ownTokenIds);
|
||||
const existing = await tx.measurement.findFirst({
|
||||
where: {
|
||||
userId: ownerId,
|
||||
@@ -378,6 +389,16 @@ export async function restoreMeasurements(ctx: RestoreContext): Promise<void> {
|
||||
const measurementBatchSize = 1_000;
|
||||
const isExpiredTombstone = await expiredTombstoneFilter(ctx);
|
||||
const { isShadowedLegacyRmssd } = ctx.measurementPrepass;
|
||||
// Tokens are not part of a backup; the ones the account holds now decide
|
||||
// which token links come back.
|
||||
const ownTokenIds = new Set(
|
||||
(
|
||||
await tx.apiToken.findMany({
|
||||
where: { userId: ownerId },
|
||||
select: { id: true },
|
||||
})
|
||||
).map((token) => token.id),
|
||||
);
|
||||
ctx.reportMeasurements(0);
|
||||
await streamed.forEachMeasurementBatch(
|
||||
measurementBatchSize,
|
||||
@@ -395,7 +416,7 @@ export async function restoreMeasurements(ctx: RestoreContext): Promise<void> {
|
||||
{
|
||||
id: measurement.id,
|
||||
userId: ownerId,
|
||||
...toRestoredMeasurementData(measurement),
|
||||
...toRestoredMeasurementData(measurement, ownTokenIds),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
@@ -412,7 +433,7 @@ export async function restoreMeasurements(ctx: RestoreContext): Promise<void> {
|
||||
}
|
||||
for (const measurement of batch) {
|
||||
if (measurement.id) continue;
|
||||
await reconcileWithoutId(tx, ownerId, measurement);
|
||||
await reconcileWithoutId(tx, ownerId, measurement, ownTokenIds);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* The Apple Health write-back rule (#1272), over every source the enum has.
|
||||
*
|
||||
* WITHINGS, IMPORT and MANUAL are true whatever the row's token says, the
|
||||
* app's own rule as it stood; EXTERNAL follows its token; everything else is
|
||||
* false. The Prisma filter the list's `appleHealthMirror=true` uses is the
|
||||
* same rule written as a `where`, checked here against the resolver for each
|
||||
* source and token state (the database agrees in
|
||||
* `tests/integration/apple-health-mirror.test.ts`).
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { MEASUREMENT_SOURCES } from "@/lib/measurements/sources";
|
||||
import {
|
||||
APPLE_HEALTH_MIRROR_WHERE,
|
||||
resolveAppleHealthMirror,
|
||||
withAppleHealthMirror,
|
||||
} from "../apple-health-mirror";
|
||||
|
||||
const TOKEN_STATES = [
|
||||
undefined,
|
||||
null,
|
||||
{ mirrorToAppleHealth: false },
|
||||
{ mirrorToAppleHealth: true },
|
||||
] as const;
|
||||
|
||||
/** The filter's two arms, evaluated against one row. */
|
||||
function matchesWhere(row: {
|
||||
source: string;
|
||||
ingestToken?: { mirrorToAppleHealth: boolean } | null;
|
||||
}): boolean {
|
||||
const [always, external] = APPLE_HEALTH_MIRROR_WHERE.OR;
|
||||
return (
|
||||
(always.source.in as readonly string[]).includes(row.source) ||
|
||||
(row.source === external.source &&
|
||||
row.ingestToken?.mirrorToAppleHealth ===
|
||||
external.ingestToken.mirrorToAppleHealth)
|
||||
);
|
||||
}
|
||||
|
||||
describe("resolveAppleHealthMirror", () => {
|
||||
it("is true for WITHINGS, IMPORT and MANUAL and an opted-in EXTERNAL", () => {
|
||||
const truthy = MEASUREMENT_SOURCES.flatMap((source) =>
|
||||
TOKEN_STATES.filter((ingestToken) =>
|
||||
resolveAppleHealthMirror({ source, ingestToken }),
|
||||
).map((ingestToken) => `${source}:${JSON.stringify(ingestToken)}`),
|
||||
);
|
||||
expect(truthy.sort()).toEqual(
|
||||
[
|
||||
...["IMPORT", "MANUAL", "WITHINGS"].flatMap((source) =>
|
||||
TOKEN_STATES.map((s) => `${source}:${JSON.stringify(s)}`),
|
||||
),
|
||||
'EXTERNAL:{"mirrorToAppleHealth":true}',
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("agrees with the list filter for every source and token state", () => {
|
||||
for (const source of MEASUREMENT_SOURCES) {
|
||||
for (const ingestToken of TOKEN_STATES) {
|
||||
expect(
|
||||
matchesWhere({ source, ingestToken }),
|
||||
`${source} ${JSON.stringify(ingestToken)}`,
|
||||
).toBe(resolveAppleHealthMirror({ source, ingestToken }));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("withAppleHealthMirror", () => {
|
||||
it("adds the decision and drops the token and its id", () => {
|
||||
const row = withAppleHealthMirror({
|
||||
id: "m1",
|
||||
source: "EXTERNAL",
|
||||
ingestTokenId: "tok-1",
|
||||
ingestToken: { mirrorToAppleHealth: true },
|
||||
});
|
||||
expect(row).toEqual({
|
||||
id: "m1",
|
||||
source: "EXTERNAL",
|
||||
appleHealthMirror: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Whether the HealthLog app writes a server measurement into Apple Health
|
||||
* (#1272). Resolved here, once, and published on every measurement row as
|
||||
* `appleHealthMirror`; the app mirrors exactly the rows that say true instead
|
||||
* of deriving the decision from `source`.
|
||||
*
|
||||
* The rule:
|
||||
*
|
||||
* - WITHINGS, IMPORT and MANUAL: true. This is the app's own rule as it
|
||||
* stands (`MeasurementSource.isServerMirrorEligible`), so publishing the
|
||||
* field changes nothing for an existing row. MANUAL is deliberate: a
|
||||
* reading typed on the web or on another device is stored as MANUAL and
|
||||
* never reached Apple Health otherwise, and a manual row the app already
|
||||
* wrote at create time is recognised by the app's own dedup (the sample
|
||||
* carries the server id), not by its source.
|
||||
* - EXTERNAL: what the person chose for the `measurements:write` token that
|
||||
* carried the reading (`ApiToken.mirrorToAppleHealth`). The source stays
|
||||
* EXTERNAL either way; a token never claims another provenance. A row with
|
||||
* no token (written before the link existed and not attributable to one)
|
||||
* is not mirrored.
|
||||
* - Everything else (APPLE_HEALTH, the provider syncs, COMPUTED, TELEGRAM,
|
||||
* MCP, …): false. Apple Health rows came from Apple Health, and the
|
||||
* provider and server-written rows are never authored into it.
|
||||
*/
|
||||
import type { MeasurementSource, Prisma } from "@/generated/prisma/client";
|
||||
|
||||
/** Sources the app mirrors whatever the token says. */
|
||||
const ALWAYS_MIRRORED: ReadonlySet<MeasurementSource> = new Set([
|
||||
"WITHINGS",
|
||||
"IMPORT",
|
||||
"MANUAL",
|
||||
]);
|
||||
|
||||
/** What a row read needs to resolve the decision. */
|
||||
export const APPLE_HEALTH_MIRROR_SELECT = {
|
||||
source: true,
|
||||
ingestToken: { select: { mirrorToAppleHealth: true } },
|
||||
} as const satisfies Prisma.MeasurementSelect;
|
||||
|
||||
/** The same, for a read or write that returns the whole row. */
|
||||
export const APPLE_HEALTH_MIRROR_INCLUDE = {
|
||||
ingestToken: APPLE_HEALTH_MIRROR_SELECT.ingestToken,
|
||||
} as const satisfies Prisma.MeasurementInclude;
|
||||
|
||||
/** A whole row read or written with `APPLE_HEALTH_MIRROR_INCLUDE`. */
|
||||
export type MeasurementWithMirrorInput = Prisma.MeasurementGetPayload<{
|
||||
include: typeof APPLE_HEALTH_MIRROR_INCLUDE;
|
||||
}>;
|
||||
|
||||
export function resolveAppleHealthMirror(row: {
|
||||
source: MeasurementSource | string;
|
||||
ingestToken?: { mirrorToAppleHealth: boolean } | null;
|
||||
}): boolean {
|
||||
if (ALWAYS_MIRRORED.has(row.source as MeasurementSource)) return true;
|
||||
if (row.source === "EXTERNAL") {
|
||||
return row.ingestToken?.mirrorToAppleHealth === true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows that resolve true, as a Prisma filter: the list's
|
||||
* `appleHealthMirror=true` filter, which the app's historical backfill pages
|
||||
* instead of one fetch per source. Kept beside the rule so the two cannot
|
||||
* disagree (pinned by `apple-health-mirror.test.ts`).
|
||||
*/
|
||||
export const APPLE_HEALTH_MIRROR_WHERE = {
|
||||
OR: [
|
||||
{ source: { in: [...ALWAYS_MIRRORED] } },
|
||||
{ source: "EXTERNAL", ingestToken: { mirrorToAppleHealth: true } },
|
||||
],
|
||||
} as const satisfies Prisma.MeasurementWhereInput;
|
||||
|
||||
/**
|
||||
* A row read with `APPLE_HEALTH_MIRROR_SELECT` (or `_INCLUDE`), answered: the
|
||||
* resolved `appleHealthMirror` added, the token relation and the token id
|
||||
* dropped, so neither ever reaches the wire.
|
||||
*/
|
||||
export function withAppleHealthMirror<
|
||||
T extends {
|
||||
source: MeasurementSource | string;
|
||||
ingestToken?: { mirrorToAppleHealth: boolean } | null;
|
||||
ingestTokenId?: string | null;
|
||||
},
|
||||
>(
|
||||
row: T,
|
||||
): Omit<T, "ingestToken" | "ingestTokenId"> & { appleHealthMirror: boolean } {
|
||||
const { ingestToken, ingestTokenId: _ingestTokenId, ...rest } = row;
|
||||
return {
|
||||
...rest,
|
||||
appleHealthMirror: resolveAppleHealthMirror({
|
||||
source: row.source,
|
||||
ingestToken,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -34,6 +34,11 @@ export interface BatchIngestInput {
|
||||
user: { id: string; timezone: string | null };
|
||||
/** A narrow single-purpose Bearer (a bridge), not the native client. */
|
||||
scoped: boolean;
|
||||
/**
|
||||
* The bridge token that carried the batch, set exactly when `scoped`: each
|
||||
* row it writes points at it (`Measurement.ingestTokenId`, #1272).
|
||||
*/
|
||||
ingestTokenId: string | null;
|
||||
entries: BatchIngestEntry[];
|
||||
syncTrigger: HealthKitRequestSyncTrigger | undefined;
|
||||
ipAddress: string | null;
|
||||
|
||||
@@ -44,9 +44,13 @@ export interface PreparedBatch {
|
||||
}
|
||||
|
||||
export async function prepareBatch(
|
||||
input: Pick<BatchIngestInput, "user" | "scoped" | "entries">,
|
||||
input: Pick<
|
||||
BatchIngestInput,
|
||||
"user" | "scoped" | "ingestTokenId" | "entries"
|
||||
>,
|
||||
): Promise<PreparedBatch> {
|
||||
const { user, scoped, entries } = input;
|
||||
const ingestTokenId = scoped ? input.ingestTokenId : null;
|
||||
const batch: PreparedBatch = {
|
||||
prepared: [],
|
||||
results: new Array(entries.length),
|
||||
@@ -94,7 +98,13 @@ export async function prepareBatch(
|
||||
continue;
|
||||
}
|
||||
|
||||
const prepared = prepareMeasurementEntry(index, entry, user.id, scoped);
|
||||
const prepared = prepareMeasurementEntry(
|
||||
index,
|
||||
entry,
|
||||
user.id,
|
||||
scoped,
|
||||
ingestTokenId,
|
||||
);
|
||||
if ("row" in prepared) batch.prepared.push(prepared);
|
||||
else batch.results[index] = prepared;
|
||||
}
|
||||
@@ -183,6 +193,7 @@ function prepareMeasurementEntry(
|
||||
entry: BatchIngestEntry,
|
||||
userId: string,
|
||||
scoped: boolean,
|
||||
ingestTokenId: string | null,
|
||||
): PreparedEntry | EntryResult {
|
||||
const mapped = mapAppleHealthEntry({
|
||||
hkIdentifier: entry.hkIdentifier,
|
||||
@@ -293,6 +304,8 @@ function prepareMeasurementEntry(
|
||||
// `(userId, type, EXTERNAL, externalId)`, which is the isolated
|
||||
// namespace this source exists to give.
|
||||
source: scoped ? EXTERNAL_SOURCE : (entry.source ?? "APPLE_HEALTH"),
|
||||
// The token an EXTERNAL row came from (#1272); null for the phone.
|
||||
ingestTokenId,
|
||||
measuredAt: mapped.takenAt,
|
||||
externalId: entry.externalId,
|
||||
externalSourceVersion: entry.externalSourceVersion ?? null,
|
||||
|
||||
@@ -92,6 +92,11 @@ function desiredFor(userId: string, p: PreparedEntry) {
|
||||
.rhythmClassification as Prisma.MeasurementUncheckedCreateInput["rhythmClassification"],
|
||||
deviceType: p.row.deviceType as string | null,
|
||||
aggregationProvenance: statsRow ? ("HEALTHKIT_STATISTICS" as const) : null,
|
||||
// Only a bridge row names its token (#1272); a phone row leaves the
|
||||
// column as it is.
|
||||
...(p.row.ingestTokenId
|
||||
? { ingestTokenId: p.row.ingestTokenId as string }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ import type { z } from "zod/v4";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { EXTERNAL_SOURCE } from "@/lib/measurements/external-source";
|
||||
import {
|
||||
APPLE_HEALTH_MIRROR_INCLUDE,
|
||||
type MeasurementWithMirrorInput,
|
||||
} from "@/lib/measurements/apple-health-mirror";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { fireAndForget } from "@/lib/logging/fire-and-forget";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -39,6 +43,11 @@ export interface ReadingWriter {
|
||||
user: { id: string; timezone: string | null };
|
||||
/** A narrow bridge credential: its rows are `EXTERNAL`. */
|
||||
scoped: boolean;
|
||||
/**
|
||||
* The bridge token, set exactly when `scoped`: each row it writes points
|
||||
* at it (`Measurement.ingestTokenId`, #1272).
|
||||
*/
|
||||
ingestTokenId: string | null;
|
||||
ipAddress: string | null;
|
||||
}
|
||||
|
||||
@@ -52,7 +61,7 @@ export const DUPLICATE_READING = { duplicate: true } as const;
|
||||
export async function createReadings(
|
||||
writer: ReadingWriter,
|
||||
input: z.output<typeof createBatchMeasurementSchema>,
|
||||
): Promise<{ rows: Measurement[] } | typeof DUPLICATE_READING> {
|
||||
): Promise<{ rows: MeasurementWithMirrorInput[] } | typeof DUPLICATE_READING> {
|
||||
const { user, scoped } = writer;
|
||||
// The caller has to be told which condition the batch hit. Untouched, a
|
||||
// duplicate escaped as an unhandled error and the batch answered 500,
|
||||
@@ -77,6 +86,7 @@ export async function createReadings(
|
||||
source: (scoped
|
||||
? EXTERNAL_SOURCE
|
||||
: (m.source ?? "MANUAL")) as MeasurementSource,
|
||||
ingestTokenId: scoped ? writer.ingestTokenId : null,
|
||||
measuredAt: m.measuredAt,
|
||||
// The note is sealed at rest; the legacy plaintext column is
|
||||
// written null for new rows.
|
||||
@@ -88,6 +98,7 @@ export async function createReadings(
|
||||
// `deviceType` instead of silently dropping it.
|
||||
deviceType: m.deviceType ?? null,
|
||||
},
|
||||
include: APPLE_HEALTH_MIRROR_INCLUDE,
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -123,7 +134,7 @@ export async function createReadings(
|
||||
export async function createReading(
|
||||
writer: ReadingWriter,
|
||||
input: z.output<typeof createMeasurementSchema>,
|
||||
): Promise<{ row: Measurement } | typeof DUPLICATE_READING> {
|
||||
): Promise<{ row: MeasurementWithMirrorInput } | typeof DUPLICATE_READING> {
|
||||
const { user, scoped } = writer;
|
||||
const {
|
||||
type,
|
||||
@@ -150,6 +161,7 @@ export async function createReading(
|
||||
source: (scoped
|
||||
? EXTERNAL_SOURCE
|
||||
: (source ?? "MANUAL")) as MeasurementSource,
|
||||
ingestTokenId: scoped ? writer.ingestTokenId : null,
|
||||
measuredAt,
|
||||
// The note is sealed at rest; the legacy plaintext column is nulled.
|
||||
notes: null,
|
||||
@@ -160,6 +172,7 @@ export async function createReading(
|
||||
// silently dropping it.
|
||||
deviceType: deviceType ?? null,
|
||||
},
|
||||
include: APPLE_HEALTH_MIRROR_INCLUDE,
|
||||
});
|
||||
} catch (err) {
|
||||
// `isP2002` rather than `instanceof Prisma.PrismaClientKnownRequestError`.
|
||||
|
||||
@@ -23,6 +23,10 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { getUnitForType } from "@/lib/validations/measurement";
|
||||
import { shapeMeasurementNotes } from "@/lib/crypto/note-cipher";
|
||||
import {
|
||||
APPLE_HEALTH_MIRROR_INCLUDE,
|
||||
withAppleHealthMirror,
|
||||
} from "@/lib/measurements/apple-health-mirror";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit";
|
||||
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
|
||||
@@ -51,6 +55,8 @@ export async function createNamedSleepStage(
|
||||
args: {
|
||||
userId: string;
|
||||
scoped: boolean;
|
||||
/** The bridge token, set exactly when `scoped` (#1272). */
|
||||
ingestTokenId: string | null;
|
||||
ipAddress: string | null;
|
||||
},
|
||||
entry: z.output<typeof sleepStageEntrySchema>,
|
||||
@@ -113,6 +119,10 @@ export async function createNamedSleepStage(
|
||||
externalSourceVersion: null,
|
||||
sleepStage: segment.stage,
|
||||
deviceType: entry.deviceType ?? null,
|
||||
// The token an EXTERNAL row came from (#1272).
|
||||
...(scoped && args.ingestTokenId
|
||||
? { ingestTokenId: args.ingestTokenId }
|
||||
: {}),
|
||||
},
|
||||
{ exactExternalMatch: "duplicate" },
|
||||
),
|
||||
@@ -190,9 +200,12 @@ async function namedSleepStageResponse(
|
||||
cross_source_merge: meta.crossSource,
|
||||
},
|
||||
});
|
||||
const row = await prisma.measurement.findUniqueOrThrow({ where: { id } });
|
||||
const row = await prisma.measurement.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: APPLE_HEALTH_MIRROR_INCLUDE,
|
||||
});
|
||||
return apiSuccess(
|
||||
{ ...shapeMeasurementNotes(row), status },
|
||||
{ ...withAppleHealthMirror(shapeMeasurementNotes(row)), status },
|
||||
status === "inserted" ? 201 : 200,
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,11 @@ import {
|
||||
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
|
||||
import { localDayWindow } from "@/lib/tz/local-day";
|
||||
import { shapeMeasurementNotes } from "@/lib/crypto/note-cipher";
|
||||
import {
|
||||
APPLE_HEALTH_MIRROR_SELECT,
|
||||
APPLE_HEALTH_MIRROR_WHERE,
|
||||
withAppleHealthMirror,
|
||||
} from "@/lib/measurements/apple-health-mirror";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import {
|
||||
readDailySeries,
|
||||
@@ -59,8 +64,18 @@ const MEASUREMENT_WIRE_SELECT = {
|
||||
deletedAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
// Resolved onto `appleHealthMirror` and dropped by `withAppleHealthMirror`.
|
||||
ingestToken: APPLE_HEALTH_MIRROR_SELECT.ingestToken,
|
||||
} as const satisfies Prisma.MeasurementSelect;
|
||||
|
||||
/** One row as it is answered: the note readable, the write-back resolved. */
|
||||
function wireRow<
|
||||
T extends Parameters<typeof shapeMeasurementNotes>[0] &
|
||||
Parameters<typeof withAppleHealthMirror>[0],
|
||||
>(row: T) {
|
||||
return withAppleHealthMirror(shapeMeasurementNotes(row));
|
||||
}
|
||||
|
||||
type ListQuery = z.output<typeof listMeasurementsSchema>;
|
||||
type ListUser = { id: string; timezone: string | null };
|
||||
|
||||
@@ -202,7 +217,7 @@ async function drillDownResponse(
|
||||
return apiSuccess({
|
||||
// The same row shape as the plain page: the note decrypted onto `notes`,
|
||||
// the ciphertext never on the wire.
|
||||
measurements: samples.map(shapeMeasurementNotes),
|
||||
measurements: samples.map(wireRow),
|
||||
meta: { total: samples.length, limit, offset: 0, dayKey },
|
||||
});
|
||||
}
|
||||
@@ -281,7 +296,7 @@ async function plainPageResponse(
|
||||
query: ListQuery,
|
||||
): Promise<Response> {
|
||||
const { type, from, to, limit, offset, sortBy, sortDir } = query;
|
||||
const { sourceEq, valueMin, valueMax } = query;
|
||||
const { sourceEq, valueMin, valueMax, appleHealthMirror } = query;
|
||||
const where = {
|
||||
userId,
|
||||
// Soft-deleted readings are invisible to every list / count / aggregate
|
||||
@@ -294,6 +309,9 @@ async function plainPageResponse(
|
||||
// opt-in (which selects the rollup read tier); `sourceEq` narrows the
|
||||
// plain list to one ingest source.
|
||||
...(sourceEq && { source: sourceEq }),
|
||||
// The rows the app mirrors into Apple Health, by the same rule the
|
||||
// field is resolved with (`apple-health-mirror.ts`).
|
||||
...(appleHealthMirror === "true" && { AND: [APPLE_HEALTH_MIRROR_WHERE] }),
|
||||
// Value-range filter. Open-ended on either side; the validator has
|
||||
// already rejected an inverted `valueMin > valueMax`.
|
||||
...(valueMin != null || valueMax != null
|
||||
@@ -330,6 +348,9 @@ async function plainPageResponse(
|
||||
orderBy: [{ [sortBy]: sortDir }, { id: sortDir }],
|
||||
take: limit,
|
||||
skip: offset,
|
||||
// Named columns, so a column added to the model (a token link) reaches
|
||||
// the wire only through this select.
|
||||
select: MEASUREMENT_WIRE_SELECT,
|
||||
}),
|
||||
prisma.measurement.count({ where }),
|
||||
]);
|
||||
@@ -339,7 +360,7 @@ async function plainPageResponse(
|
||||
return apiSuccess({
|
||||
// Decrypt `notesEncrypted` onto `notes` and strip the ciphertext before
|
||||
// it leaves the server (iOS MeasurementWireDTO contract).
|
||||
measurements: measurements.map(shapeMeasurementNotes),
|
||||
measurements: measurements.map(wireRow),
|
||||
meta: { total, limit, offset },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Switching a measurement token's Apple Health write-back (#1272), for
|
||||
* `PATCH /api/tokens/{id}`.
|
||||
*
|
||||
* One statement on the token row. The readings the token carried are not
|
||||
* touched: their `updatedAt` and `syncVersion` stay as they are, so nothing
|
||||
* keyed on them (the rollup freshness probe, personal-record detection, the
|
||||
* status re-warm) sees a change that is not one. The rows reach a paired
|
||||
* client again through the `measurementMirror` domain of `/api/sync/changes`,
|
||||
* keyed on the `mirrorChangedAt` this stamps.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
|
||||
|
||||
/** A token minted by `POST /api/tokens/measurements`, and nothing else. */
|
||||
export function isMeasurementToken(permissions: readonly string[]): boolean {
|
||||
return (
|
||||
permissions.length === 1 && permissions[0] === MEASUREMENTS_WRITE_SCOPE
|
||||
);
|
||||
}
|
||||
|
||||
export type TokenMirrorOutcome =
|
||||
| { kind: "not_found" }
|
||||
| { kind: "not_measurement_token" }
|
||||
| { kind: "set"; mirrorToAppleHealth: boolean };
|
||||
|
||||
export async function setMeasurementTokenMirror(input: {
|
||||
userId: string;
|
||||
tokenId: string;
|
||||
mirrorToAppleHealth: boolean;
|
||||
ipAddress: string | null;
|
||||
}): Promise<TokenMirrorOutcome> {
|
||||
const { userId, tokenId, mirrorToAppleHealth } = input;
|
||||
const token = await prisma.apiToken.findUnique({
|
||||
where: { id: tokenId },
|
||||
select: { userId: true, permissions: true, mirrorToAppleHealth: true },
|
||||
});
|
||||
// Another account's token is answered as missing, like the revoke.
|
||||
if (!token || token.userId !== userId) return { kind: "not_found" };
|
||||
// A revoked token is accepted: its readings stay in the record, and
|
||||
// whether they are mirrored is still the person's choice.
|
||||
if (!isMeasurementToken(token.permissions)) {
|
||||
return { kind: "not_measurement_token" };
|
||||
}
|
||||
|
||||
const changed = token.mirrorToAppleHealth !== mirrorToAppleHealth;
|
||||
if (changed) {
|
||||
await prisma.apiToken.update({
|
||||
where: { id: tokenId },
|
||||
data: { mirrorToAppleHealth, mirrorChangedAt: new Date() },
|
||||
});
|
||||
await auditLog("tokens.measurements.mirror.update", {
|
||||
userId,
|
||||
ipAddress: input.ipAddress,
|
||||
details: { tokenId, mirrorToAppleHealth },
|
||||
});
|
||||
}
|
||||
annotate({ meta: { mirror_to_apple_health: mirrorToAppleHealth, changed } });
|
||||
return { kind: "set", mirrorToAppleHealth };
|
||||
}
|
||||
@@ -450,6 +450,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
tokens: [
|
||||
"tokens.documents.ceiling_reached",
|
||||
"tokens.measurements.ceiling_reached",
|
||||
"tokens.mirror.not_measurement_token",
|
||||
"tokens.workouts.ceiling_reached",
|
||||
],
|
||||
vaccination: [
|
||||
|
||||
@@ -34,6 +34,7 @@ import { oidcNativeTokenSchema } from "@/lib/validations/oidc-native";
|
||||
import {
|
||||
createDocumentTokenSchema,
|
||||
createMeasurementTokenSchema,
|
||||
updateMeasurementTokenSchema,
|
||||
createWorkoutTokenSchema,
|
||||
} from "@/lib/validations/tokens";
|
||||
import { nativeHandoffTokenSchema } from "@/lib/validations/native-handoff";
|
||||
@@ -484,9 +485,36 @@ const apiTokenInfo = z
|
||||
.describe(
|
||||
"Revoked tokens stay in the list rather than disappearing, so a client must filter on this rather than treat presence as validity.",
|
||||
),
|
||||
mirrorToAppleHealth: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"A measurement token's Apple Health setting: whether the HealthLog app writes the readings this token sent into Apple Health. Changed with `PATCH /api/tokens/{id}`; false on every other token.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "ApiTokenInfo" });
|
||||
|
||||
const updateMeasurementTokenRequest = updateMeasurementTokenSchema.meta({
|
||||
id: "UpdateMeasurementTokenRequest",
|
||||
description:
|
||||
"The one setting a measurement token has. Strict: any other key is refused 422.",
|
||||
});
|
||||
|
||||
const updateMeasurementTokenResponse = z
|
||||
.object({ id: z.string(), mirrorToAppleHealth: z.boolean() })
|
||||
.meta({ id: "UpdateMeasurementTokenResponse" });
|
||||
|
||||
const measurementTokenSummaryResponse = z
|
||||
.object({
|
||||
unattributedReadings: z
|
||||
.number()
|
||||
.int()
|
||||
.nonnegative()
|
||||
.describe(
|
||||
"Live EXTERNAL readings that belong to no token: sent before readings were linked to their token, by an account that held more than one. No token's Apple Health setting reaches them.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "MeasurementTokenSummaryResponse" });
|
||||
|
||||
const apiTokenListResponse = z
|
||||
.array(apiTokenInfo)
|
||||
.meta({ id: "ApiTokenListResponse" });
|
||||
@@ -494,7 +522,7 @@ const apiTokenListResponse = z
|
||||
const createMeasurementTokenRequest = createMeasurementTokenSchema.meta({
|
||||
id: "CreateMeasurementTokenRequest",
|
||||
description:
|
||||
"Mint a measurement-ingest Bearer. There is no `scope` field: this endpoint mints one shape and the permission array is a literal, so no request can widen it. `name` is what the token is listed under — name it after the thing you are pasting it into, because that is what you will be reading when you decide whether to revoke it. `expiresInDays` defaults to 365.",
|
||||
"Mint a measurement-ingest Bearer. There is no `scope` field: this endpoint mints one shape and the permission array is a literal, so no request can widen it. `name` is what the token is listed under — name it after the thing you are pasting it into, because that is what you will be reading when you decide whether to revoke it. `expiresInDays` defaults to 365. `mirrorToAppleHealth` (default false) lets the HealthLog app write the token's readings into Apple Health; it can be changed later at `PATCH /api/tokens/{id}`.",
|
||||
});
|
||||
|
||||
const createMeasurementTokenResponse = z
|
||||
@@ -1944,6 +1972,26 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
"/api/tokens/measurements": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
summary: "Readings no measurement token reaches",
|
||||
description:
|
||||
"Counts the caller's live EXTERNAL readings that are linked to no token. Readings are linked to the token that sent them since v1.46; older ones were linked by a one-time pass only when the account held exactly one measurement token, so the rest belong to none and no token's Apple Health setting reaches them. Cookie session only.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The count.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
measurementTokenSummaryResponse,
|
||||
"MeasurementTokenSummaryEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Auth"],
|
||||
summary: "Mint a measurement-ingest token",
|
||||
@@ -2170,6 +2218,42 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
patch: {
|
||||
tags: ["Auth"],
|
||||
summary: "Change a measurement token's Apple Health setting",
|
||||
description:
|
||||
"Sets whether the HealthLog app writes the readings this measurement token sent into Apple Health. The readings keep their `EXTERNAL` source and are not rewritten: every measurement row resolves `appleHealthMirror` from the token at read time, and a paired client learns of the change through the `measurementMirror` domain of `GET /api/sync/changes`. Turning it off stops further writes; samples already in Apple Health stay. A revoked token is accepted, since its readings stay in the record. Cookie session only, the caller's own tokens only (another account's answers 404). Audited as `tokens.measurements.mirror.update` when the value changes. 30 changes per account per minute. Not gated on the instance-wide API switch: no credential is created or extended.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: updateMeasurementTokenRequest },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The setting as it now stands.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
updateMeasurementTokenResponse,
|
||||
"UpdateMeasurementTokenEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "Token not found or not owned by the caller.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
"422": {
|
||||
description:
|
||||
"Validation failed, or the token is not a measurement token (`meta.errorCode` = `tokens.mirror.not_measurement_token`): a wildcard, connector or other narrow token has no such setting.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// ── Appended: registration, credential rotation, the OIDC entry point and
|
||||
// the Codex device-auth flow.
|
||||
|
||||
@@ -332,6 +332,12 @@ export const measurementResource = z
|
||||
.describe("Soft-delete tombstone; null on a live row."),
|
||||
createdAt: isoDateTime().optional(),
|
||||
updatedAt: isoDateTime().optional(),
|
||||
appleHealthMirror: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Whether the HealthLog app writes this reading into Apple Health. Resolved by the server on every stored row: true for WITHINGS, IMPORT and MANUAL, for an EXTERNAL row whatever its account chose for the token that sent it, false otherwise. Absent on the synthesised list rows.",
|
||||
),
|
||||
// Synthesised-row extras — present only on the collapsed day-sum mode.
|
||||
dayKey: z.string().optional(),
|
||||
sampleCount: z.number().int().optional(),
|
||||
|
||||
@@ -75,9 +75,29 @@ const syncMeasurementUpsert = measurementResource
|
||||
"LWW reconciliation counter; echo to keep the mirror monotonic.",
|
||||
),
|
||||
updatedAt: isoDateTime(),
|
||||
appleHealthMirror: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"Whether the HealthLog app writes this reading into Apple Health. Server-resolved; mirror exactly the rows that say true.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "SyncMeasurementUpsert" });
|
||||
|
||||
const syncMeasurementMirrorUpsert = z
|
||||
.object({
|
||||
id: z.string().describe("The measurement's server id."),
|
||||
appleHealthMirror: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"The reading's write-back as it now stands. False means stop writing it; samples already in Apple Health stay.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "SyncMeasurementMirrorUpsert",
|
||||
description:
|
||||
"A reading whose `appleHealthMirror` changed because the account switched the Apple Health setting of the token that sent it. The reading itself did not change and is not re-sent in `measurements`.",
|
||||
});
|
||||
|
||||
const syncMeasurementTombstone = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
@@ -294,12 +314,25 @@ export const syncChangesResponse = z
|
||||
.describe(
|
||||
"Menstrual cycles, observed and forward-predicted. Present on every page, empty arrays when the account has the cycle module off.",
|
||||
),
|
||||
measurementMirror: z
|
||||
.object({
|
||||
upserts: z.array(syncMeasurementMirrorUpsert),
|
||||
tombstones: z
|
||||
.array(z.object({ id: z.string() }))
|
||||
.max(0)
|
||||
.describe(
|
||||
"Always empty: a deleted reading is a measurement tombstone.",
|
||||
),
|
||||
})
|
||||
.describe(
|
||||
"Apple Health write-back changes for readings that are otherwise unchanged. Apply after `measurements`.",
|
||||
),
|
||||
}),
|
||||
})
|
||||
.meta({
|
||||
id: "SyncChangesResponse",
|
||||
description:
|
||||
"Multi-domain delta page: measurements, mood, intakes, cycle day logs and cycles. One opaque multi-domain keyset cursor; tombstones apply before upserts within each domain. Tombstone identity: measurements key on externalId, every other domain on server id. All five keys are present on every response, including the `cursorExpired` short-circuit — a domain with nothing to report sends empty arrays rather than being omitted.",
|
||||
"Multi-domain delta page: measurements, mood, intakes, cycle day logs, cycles, and the Apple Health write-back changes of otherwise unchanged readings. One opaque multi-domain keyset cursor; tombstones apply before upserts within each domain. Tombstone identity: measurements key on externalId, every other domain on server id. All six keys are present on every response, including the `cursorExpired` short-circuit — a domain with nothing to report sends empty arrays rather than being omitted.",
|
||||
});
|
||||
|
||||
export const syncPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
@@ -327,7 +360,7 @@ export const syncPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Sync"],
|
||||
summary: "Multi-domain delta feed",
|
||||
description:
|
||||
"Incremental catch-up after the first-pair backfill (never a replacement for it). Pages over one opaque keyset cursor spanning five domains — measurements, mood, medication intakes, cycle day logs and cycles. Each domain carries `tombstones` (soft-deleted rows) and `upserts` (live rows); apply tombstones before upserts within a domain. Tombstone identity is `externalId` for measurements and the server `id` everywhere else. `cursorExpired: true` forces a clean re-init and the page is empty in every domain.",
|
||||
"Incremental catch-up after the first-pair backfill (never a replacement for it). Pages over one opaque keyset cursor spanning six domains — measurements, mood, medication intakes, cycle day logs, cycles, and `measurementMirror` (the Apple Health write-back of readings whose token setting changed). Each domain carries `tombstones` (soft-deleted rows) and `upserts` (live rows); apply tombstones before upserts within a domain. Tombstone identity is `externalId` for measurements and the server `id` everywhere else. `cursorExpired: true` forces a clean re-init and the page is empty in every domain.",
|
||||
requestParams: {
|
||||
query: syncChangesQuery,
|
||||
},
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
*/
|
||||
export const integrationKeys = {
|
||||
tokens: () => ["tokens"] as const,
|
||||
// How many EXTERNAL readings belong to no measurement token (#1272). Under
|
||||
// the `["tokens"]` prefix, so a mint or a revoke refreshes it too.
|
||||
measurementTokenSummary: () => ["tokens", "measurements", "summary"] as const,
|
||||
// v1.22.0 — MCP connector tokens (the dedicated `health:read` scope minted
|
||||
// for the remote MCP endpoint). Distinct key from `tokens()` so the MCP
|
||||
// settings card's mint / revoke mutations invalidate only this list.
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
loadFoldTimezone,
|
||||
} from "@/lib/measurements/folded-window";
|
||||
import { readNote } from "@/lib/crypto/note-cipher";
|
||||
import { resolveAppleHealthMirror } from "@/lib/measurements/apple-health-mirror";
|
||||
import { levelAForWire } from "@/lib/mood/level-a";
|
||||
import { contextForWire } from "@/lib/mood/context";
|
||||
import {
|
||||
@@ -22,6 +23,7 @@ import type {
|
||||
CycleDayRow,
|
||||
CycleRow,
|
||||
IntakeRow,
|
||||
MeasurementMirrorRow,
|
||||
MeasurementRow,
|
||||
MoodRow,
|
||||
} from "./read-rows";
|
||||
@@ -30,6 +32,7 @@ import type {
|
||||
DomainBlock,
|
||||
IdTombstone,
|
||||
IntakeUpsert,
|
||||
MeasurementMirrorUpsert,
|
||||
MeasurementTombstone,
|
||||
MeasurementUpsert,
|
||||
MoodUpsert,
|
||||
@@ -129,6 +132,7 @@ export async function measurementBlock(
|
||||
measuredAt: row.measuredAt.toISOString(),
|
||||
source: row.source,
|
||||
notes: readNote(row.notesEncrypted, row.notes),
|
||||
appleHealthMirror: resolveAppleHealthMirror(row),
|
||||
syncVersion: row.syncVersion,
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
});
|
||||
@@ -137,6 +141,18 @@ export async function measurementBlock(
|
||||
return { upserts, tombstones };
|
||||
}
|
||||
|
||||
export function measurementMirrorBlock(
|
||||
page: MeasurementMirrorRow[],
|
||||
): DomainBlock<MeasurementMirrorUpsert, never> {
|
||||
return {
|
||||
upserts: page.map((row) => ({
|
||||
id: row.id,
|
||||
appleHealthMirror: resolveAppleHealthMirror(row),
|
||||
})),
|
||||
tombstones: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function moodBlock(
|
||||
page: MoodRow[],
|
||||
): DomainBlock<MoodUpsert, IdTombstone> {
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
cycleDayBlock,
|
||||
intakeBlock,
|
||||
measurementBlock,
|
||||
measurementMirrorBlock,
|
||||
moodBlock,
|
||||
pageOf,
|
||||
} from "./domain-pages";
|
||||
@@ -72,7 +73,11 @@ async function expiry(
|
||||
const retentionHorizon = new Date(
|
||||
serverNow.getTime() - TOMBSTONE_RETENTION_DAYS * DAY_MS,
|
||||
);
|
||||
// Not `measurementMirror`: its watermark is when a token's setting last
|
||||
// changed, which can be any age, and the domain re-delivers values, not
|
||||
// deletions, so no pruned tombstone can hide behind it.
|
||||
const pastRetention = SYNC_DOMAINS.some((domain) => {
|
||||
if (domain === "measurementMirror") return false;
|
||||
const wm = cursor[domain];
|
||||
return wm !== undefined && wm.updatedAtMs < retentionHorizon.getTime();
|
||||
});
|
||||
@@ -99,6 +104,7 @@ function emptyChanges(): SyncChangesBody["changes"] {
|
||||
intakes: { upserts: [], tombstones: [] },
|
||||
cycleDays: { upserts: [], tombstones: [] },
|
||||
cycles: { upserts: [], tombstones: [] },
|
||||
measurementMirror: { upserts: [], tombstones: [] },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -186,8 +192,25 @@ export async function pullSyncChanges(input: {
|
||||
const cycles = cycleBlock(cy.page);
|
||||
advanceCursor(nextCursor, "cycles", cy.page);
|
||||
|
||||
const mm = pageOf(rows.measurementMirror, limit);
|
||||
const measurementMirror = measurementMirrorBlock(mm.page);
|
||||
advanceCursor(
|
||||
nextCursor,
|
||||
"measurementMirror",
|
||||
mm.page.map((row) => ({
|
||||
// The walk only returns rows whose token carries a change stamp.
|
||||
updatedAt: row.ingestToken!.mirrorChangedAt!,
|
||||
id: row.id,
|
||||
})),
|
||||
);
|
||||
|
||||
const hasMore =
|
||||
m.hasMore || md.hasMore || ik.hasMore || cd.hasMore || cy.hasMore;
|
||||
m.hasMore ||
|
||||
md.hasMore ||
|
||||
ik.hasMore ||
|
||||
cd.hasMore ||
|
||||
cy.hasMore ||
|
||||
mm.hasMore;
|
||||
|
||||
annotate({
|
||||
action: { name: "sync.changes.pull" },
|
||||
@@ -202,6 +225,7 @@ export async function pullSyncChanges(input: {
|
||||
cycle_day_tombstones: cycleDays.tombstones.length,
|
||||
cycle_upserts: cycles.upserts.length,
|
||||
cycle_tombstones: cycles.tombstones.length,
|
||||
measurement_mirror_upserts: measurementMirror.upserts.length,
|
||||
cycle_module_enabled: cycleEnabled,
|
||||
has_more: hasMore,
|
||||
cursor_present: Boolean(token),
|
||||
@@ -213,6 +237,13 @@ export async function pullSyncChanges(input: {
|
||||
cursor: encodeCursor(nextCursor, serverNow.getTime()),
|
||||
hasMore,
|
||||
cursorExpired: false,
|
||||
changes: { measurements, mood, intakes, cycleDays, cycles },
|
||||
changes: {
|
||||
measurements,
|
||||
mood,
|
||||
intakes,
|
||||
cycleDays,
|
||||
cycles,
|
||||
measurementMirror,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { prisma } from "@/lib/db";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import type { DomainWatermark, SyncCursor } from "@/lib/sync/cursor";
|
||||
import { dayLogSymptomInclude } from "@/lib/cycle/dto";
|
||||
import { APPLE_HEALTH_MIRROR_SELECT } from "@/lib/measurements/apple-health-mirror";
|
||||
|
||||
const MEASUREMENT_SELECT = {
|
||||
id: true,
|
||||
@@ -24,6 +25,13 @@ const MEASUREMENT_SELECT = {
|
||||
syncVersion: true,
|
||||
deletedAt: true,
|
||||
updatedAt: true,
|
||||
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 = {
|
||||
@@ -73,6 +81,9 @@ export type CycleDayRow = Prisma.CycleDayLogGetPayload<{
|
||||
include: typeof dayLogSymptomInclude;
|
||||
}>;
|
||||
export type CycleRow = Prisma.MenstrualCycleGetPayload<object>;
|
||||
export type MeasurementMirrorRow = Prisma.MeasurementGetPayload<{
|
||||
select: typeof MEASUREMENT_MIRROR_SELECT;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Build the `(updatedAt, id)` keyset predicate for a domain's watermark.
|
||||
@@ -95,6 +106,44 @@ export interface DomainRows {
|
||||
intakes: IntakeRow[];
|
||||
cycleDays: CycleDayRow[];
|
||||
cycles: CycleRow[];
|
||||
measurementMirror: MeasurementMirrorRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function readMeasurementMirrorRows(
|
||||
userId: string,
|
||||
wm: DomainWatermark | undefined,
|
||||
limit: number,
|
||||
): Promise<MeasurementMirrorRow[]> {
|
||||
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: MEASUREMENT_MIRROR_SELECT,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,40 +157,42 @@ export async function readDomainRows(
|
||||
limit: number,
|
||||
cycleEnabled: boolean,
|
||||
): Promise<DomainRows> {
|
||||
const [measurements, mood, intakes, cycleDays, cycles] = await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where: { userId, ...keysetFilter(cursor.measurements) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
select: MEASUREMENT_SELECT,
|
||||
}),
|
||||
prisma.moodEntry.findMany({
|
||||
where: { userId, ...keysetFilter(cursor.mood) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
select: MOOD_SELECT,
|
||||
}),
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: { userId, ...keysetFilter(cursor.intakes) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
select: INTAKE_SELECT,
|
||||
}),
|
||||
cycleEnabled
|
||||
? prisma.cycleDayLog.findMany({
|
||||
where: { userId, ...keysetFilter(cursor.cycleDays) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
include: dayLogSymptomInclude,
|
||||
})
|
||||
: [],
|
||||
cycleEnabled
|
||||
? prisma.menstrualCycle.findMany({
|
||||
where: { userId, ...keysetFilter(cursor.cycles) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
return { measurements, mood, intakes, cycleDays, cycles };
|
||||
const [measurements, mood, intakes, cycleDays, cycles, measurementMirror] =
|
||||
await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where: { userId, ...keysetFilter(cursor.measurements) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
select: MEASUREMENT_SELECT,
|
||||
}),
|
||||
prisma.moodEntry.findMany({
|
||||
where: { userId, ...keysetFilter(cursor.mood) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
select: MOOD_SELECT,
|
||||
}),
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: { userId, ...keysetFilter(cursor.intakes) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
select: INTAKE_SELECT,
|
||||
}),
|
||||
cycleEnabled
|
||||
? prisma.cycleDayLog.findMany({
|
||||
where: { userId, ...keysetFilter(cursor.cycleDays) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
include: dayLogSymptomInclude,
|
||||
})
|
||||
: [],
|
||||
cycleEnabled
|
||||
? prisma.menstrualCycle.findMany({
|
||||
where: { userId, ...keysetFilter(cursor.cycles) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
})
|
||||
: [],
|
||||
readMeasurementMirrorRows(userId, cursor.measurementMirror, limit),
|
||||
]);
|
||||
return { measurements, mood, intakes, cycleDays, cycles, measurementMirror };
|
||||
}
|
||||
@@ -19,6 +19,8 @@ export interface MeasurementUpsert {
|
||||
measuredAt: string;
|
||||
source: string;
|
||||
notes: string | null;
|
||||
/** Whether the app writes the reading into Apple Health (#1272). */
|
||||
appleHealthMirror: boolean;
|
||||
syncVersion: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -97,12 +99,23 @@ export interface DomainBlock<U, T> {
|
||||
tombstones: T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A reading whose Apple Health write-back changed because its token's
|
||||
* setting did (#1272). The reading itself did not change and is not re-sent.
|
||||
*/
|
||||
export interface MeasurementMirrorUpsert {
|
||||
id: string;
|
||||
appleHealthMirror: boolean;
|
||||
}
|
||||
|
||||
export interface SyncChanges {
|
||||
measurements: DomainBlock<MeasurementUpsert, MeasurementTombstone>;
|
||||
mood: DomainBlock<MoodUpsert, IdTombstone>;
|
||||
intakes: DomainBlock<IntakeUpsert, IdTombstone>;
|
||||
cycleDays: DomainBlock<CycleDayLogDTO, CycleDayTombstone>;
|
||||
cycles: DomainBlock<MenstrualCycleDTO, IdTombstone>;
|
||||
/** Never carries tombstones: a deleted reading is a measurement tombstone. */
|
||||
measurementMirror: DomainBlock<MeasurementMirrorUpsert, never>;
|
||||
}
|
||||
|
||||
export interface SyncChangesBody {
|
||||
|
||||
+17
-2
@@ -28,19 +28,34 @@
|
||||
|
||||
/** The sync domains the feed serves. */
|
||||
export type SyncDomain =
|
||||
"measurements" | "mood" | "intakes" | "cycleDays" | "cycles";
|
||||
| "measurements"
|
||||
| "mood"
|
||||
| "intakes"
|
||||
| "cycleDays"
|
||||
| "cycles"
|
||||
| "measurementMirror";
|
||||
|
||||
/**
|
||||
* `measurementMirror` (#1272) is additive inside the v2 envelope: a token
|
||||
* without it decodes as before and that domain scans from the start. Its
|
||||
* watermark is not a row's `updatedAt` but the `mirrorChangedAt` of the
|
||||
* token whose readings were last re-delivered (see `read-rows.ts`).
|
||||
*/
|
||||
export const SYNC_DOMAINS: readonly SyncDomain[] = [
|
||||
"measurements",
|
||||
"mood",
|
||||
"intakes",
|
||||
"cycleDays",
|
||||
"cycles",
|
||||
"measurementMirror",
|
||||
];
|
||||
|
||||
/** A single domain's keyset position. */
|
||||
export interface DomainWatermark {
|
||||
/** `updatedAt` of the last drained row, as epoch milliseconds. */
|
||||
/**
|
||||
* `updatedAt` of the last drained row, as epoch milliseconds; for
|
||||
* `measurementMirror`, the `mirrorChangedAt` of that row's token.
|
||||
*/
|
||||
updatedAtMs: number;
|
||||
/** `id` of the last drained row (cuid) — the keyset tie-breaker. */
|
||||
id: string;
|
||||
|
||||
@@ -394,6 +394,10 @@ export const listMeasurementsSchema = z
|
||||
// threaded into the plain-list `where`. Backed by the `(userId, source,
|
||||
// measuredAt)` index (migration 0136).
|
||||
sourceEq: measurementSourceEnum.optional(),
|
||||
// Only the rows the HealthLog app writes into Apple Health
|
||||
// (`appleHealthMirror`, #1272): the app's historical backfill pages this
|
||||
// instead of one fetch per source. A plain-list filter.
|
||||
appleHealthMirror: z.enum(["true"]).optional(),
|
||||
// v1.18.5 — value-range FILTER (backlog G). Narrows the plain list to
|
||||
// readings whose `value` falls within an optional `[valueMin, valueMax]`
|
||||
// band. Either bound may be omitted for an open-ended range. Coerced
|
||||
@@ -478,6 +482,23 @@ export const listMeasurementsSchema = z
|
||||
message: "valueMin must be <= valueMax",
|
||||
path: ["valueMin"],
|
||||
},
|
||||
)
|
||||
// `appleHealthMirror` narrows the plain row list. The other modes answer
|
||||
// something else (night rows, day buckets, a drill-down, a series), where
|
||||
// a row filter would either do nothing or change what the mode means.
|
||||
.refine(
|
||||
({ appleHealthMirror, type, dayKey, groupBy, aggregate, source }) =>
|
||||
appleHealthMirror === undefined ||
|
||||
(type !== "SLEEP_DURATION" &&
|
||||
dayKey === undefined &&
|
||||
groupBy === undefined &&
|
||||
source === undefined &&
|
||||
(aggregate === undefined || aggregate === "raw")),
|
||||
{
|
||||
message:
|
||||
"appleHealthMirror filters the plain row list only; combine it with type, from, to, sourceEq and paging",
|
||||
path: ["appleHealthMirror"],
|
||||
},
|
||||
);
|
||||
|
||||
export const createBatchMeasurementSchema = z.object({
|
||||
|
||||
@@ -19,6 +19,21 @@ import { z } from "zod/v4";
|
||||
export const createMeasurementTokenSchema = z.object({
|
||||
name: z.string().min(1, "Name required").max(100),
|
||||
expiresInDays: z.number().int().min(1).max(365).optional(),
|
||||
/**
|
||||
* Whether the HealthLog app writes this token's readings into Apple Health
|
||||
* (#1272). Off unless the person turns it on; changeable later with
|
||||
* `PATCH /api/tokens/{id}`.
|
||||
*/
|
||||
mirrorToAppleHealth: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Body of `PATCH /api/tokens/{id}`: the one setting a measurement token has.
|
||||
* Strict, so a key this route does not understand (a `permissions`, a
|
||||
* `name`) is refused rather than ignored.
|
||||
*/
|
||||
export const updateMeasurementTokenSchema = z.strictObject({
|
||||
mirrorToAppleHealth: z.boolean(),
|
||||
});
|
||||
|
||||
export type CreateMeasurementTokenInput = z.infer<
|
||||
|
||||
Reference in new issue
Block a user