mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(measurements): resolve each reading's Apple Health write-back
A reading sent with a measurements:write token keeps its EXTERNAL source and now points at the token that carried it (measurements.ingest_token_id). Every stored row answers a server-resolved appleHealthMirror: true for WITHINGS, IMPORT and MANUAL, the token's choice for EXTERNAL, false otherwise. The plain list accepts appleHealthMirror=true so a client can page exactly those rows. Migration 0476 adds the columns and links existing EXTERNAL readings only where one measurement token provably sent them; 0477 builds the index concurrently. Backups carry the link and a restore keeps it only for a token the account still holds. A guard holds every EXTERNAL writer to setting the link.
This commit is contained in:
1 parent
a0dce10c1a
commit
8d10e5ab94
40 files changed
+1014
-142
No files matched your search
@@ -0,0 +1,80 @@
|
||||
-- Per-token Apple Health write-back (#1272).
|
||||
--
|
||||
-- `api_tokens.mirror_to_apple_health` is the person's choice for a
|
||||
-- `measurements:write` token; `mirror_changed_at` is when it last changed and
|
||||
-- keys the `measurementMirror` domain of `/api/sync/changes`. Rows written by
|
||||
-- such a token keep their honest `EXTERNAL` source and point at the token
|
||||
-- through `measurements.ingest_token_id`; the server resolves the row's
|
||||
-- `appleHealthMirror` from the token when it answers it.
|
||||
--
|
||||
-- Each step is a `DO` block with a bounded lock wait, and each is safe to run
|
||||
-- again, so the file is marked for the boot's lock-timeout retry:
|
||||
--
|
||||
-- 1. the columns, and the foreign key added NOT VALID (no scan under the
|
||||
-- table lock);
|
||||
-- 2. the backfill: an existing EXTERNAL row is linked only where the
|
||||
-- account has exactly one measurement token (revoked ones counted) and
|
||||
-- the row is no older than that token, because only then is the token
|
||||
-- provably the one that carried it. Every other existing row stays
|
||||
-- unlinked and is not mirrored. The statement sets nothing but
|
||||
-- `ingest_token_id`, so `updated_at` and `sync_version` are untouched;
|
||||
-- 3. the foreign key validated, which scans without blocking writes.
|
||||
--
|
||||
-- The index the key and the feed walk use is built CONCURRENTLY in 0477, on
|
||||
-- its own, because a concurrent build cannot share a file.
|
||||
--
|
||||
-- @retry-on-lock-timeout
|
||||
|
||||
DO $migration$
|
||||
BEGIN
|
||||
PERFORM set_config('lock_timeout', '5s', true);
|
||||
ALTER TABLE "api_tokens"
|
||||
ADD COLUMN IF NOT EXISTS "mirror_to_apple_health" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS "mirror_changed_at" TIMESTAMP(3);
|
||||
ALTER TABLE "measurements"
|
||||
ADD COLUMN IF NOT EXISTS "ingest_token_id" TEXT;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'measurements_ingest_token_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE "measurements"
|
||||
ADD CONSTRAINT "measurements_ingest_token_id_fkey"
|
||||
FOREIGN KEY ("ingest_token_id") REFERENCES "api_tokens"("id")
|
||||
ON DELETE SET NULL ON UPDATE CASCADE NOT VALID;
|
||||
END IF;
|
||||
END
|
||||
$migration$;
|
||||
|
||||
DO $migration$
|
||||
DECLARE
|
||||
linked bigint;
|
||||
BEGIN
|
||||
PERFORM set_config('lock_timeout', '5s', true);
|
||||
WITH sole_token AS (
|
||||
SELECT t."user_id",
|
||||
(array_agg(t."id"))[1] AS token_id,
|
||||
(array_agg(t."created_at"))[1] AS created_at
|
||||
FROM "api_tokens" t
|
||||
WHERE t."permissions" = ARRAY['measurements:write']::text[]
|
||||
GROUP BY t."user_id"
|
||||
HAVING count(*) = 1
|
||||
)
|
||||
UPDATE "measurements" m
|
||||
SET "ingest_token_id" = s.token_id
|
||||
FROM sole_token s
|
||||
WHERE m."user_id" = s."user_id"
|
||||
AND m."source" = 'EXTERNAL'
|
||||
AND m."ingest_token_id" IS NULL
|
||||
AND m."created_at" >= s.created_at;
|
||||
GET DIAGNOSTICS linked = ROW_COUNT;
|
||||
RAISE NOTICE '0476: linked % EXTERNAL readings to their sole measurement token', linked;
|
||||
END
|
||||
$migration$;
|
||||
|
||||
DO $migration$
|
||||
BEGIN
|
||||
PERFORM set_config('lock_timeout', '5s', true);
|
||||
ALTER TABLE "measurements"
|
||||
VALIDATE CONSTRAINT "measurements_ingest_token_id_fkey";
|
||||
END
|
||||
$migration$;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- The rows a measurement token carried (#1272): walked by the
|
||||
-- `measurementMirror` domain of `/api/sync/changes` in id order, and looked up
|
||||
-- by the foreign key's SET NULL. Built CONCURRENTLY so writes to
|
||||
-- `measurements` carry on during the build; a concurrent build must be the
|
||||
-- only statement in its file. Most rows have no token, so the index stays
|
||||
-- small.
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS "measurements_ingest_token_id_id_idx"
|
||||
ON "measurements" ("ingest_token_id", "id");
|
||||
+34
-13
@@ -2118,8 +2118,15 @@ model Measurement {
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
/// The `measurements:write` token that carried this reading, set on every
|
||||
/// `EXTERNAL` row (#1272). Read through to the token's `mirrorToAppleHealth`
|
||||
/// when the row is answered; never on the wire itself. SET NULL if the token
|
||||
/// row is ever removed (a revoke keeps it), and the row then stops being
|
||||
/// mirrored.
|
||||
ingestTokenId String? @map("ingest_token_id")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
ingestToken ApiToken? @relation(fields: [ingestTokenId], references: [id], onDelete: SetNull)
|
||||
// v1.19.0 — optional back-relation to the full ECG waveform recording(s)
|
||||
// paired with this EVENT row. SET NULL on the FK side keeps the trace if
|
||||
// the EVENT row is re-keyed / removed.
|
||||
@@ -2175,6 +2182,9 @@ model Measurement {
|
||||
// changes nothing else, so those stay HOT; the table also carries
|
||||
// `fillfactor = 85` and a 5 % autovacuum threshold from the same migration.
|
||||
@@index([userId, updatedAt, id])
|
||||
// The rows a token carried, walked by the `measurementMirror` feed domain
|
||||
// in id order and by the FK's SET NULL (migration 0477).
|
||||
@@index([ingestTokenId, id])
|
||||
// `measurements_legacy_step_consolidation_idx` is a partial index
|
||||
// over `type = 'ACTIVITY_STEPS' AND deleted_at IS NULL` — see
|
||||
// migration 0087. It accelerates the v1.5.6 legacy step-consolidation
|
||||
@@ -3936,26 +3946,37 @@ model TelegramPromptContext {
|
||||
// ─── API Tokens (for external ingest) ──────────────────────
|
||||
|
||||
model ApiToken {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
name String
|
||||
tokenHash String @unique @map("token_hash") // SHA-256 of the token
|
||||
permissions String[] @default(["medication:ingest"])
|
||||
lastUsedAt DateTime? @map("last_used_at")
|
||||
expiresAt DateTime? @map("expires_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
revoked Boolean @default(false)
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
name String
|
||||
tokenHash String @unique @map("token_hash") // SHA-256 of the token
|
||||
permissions String[] @default(["medication:ingest"])
|
||||
lastUsedAt DateTime? @map("last_used_at")
|
||||
expiresAt DateTime? @map("expires_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
revoked Boolean @default(false)
|
||||
// The MCP OAuth connection this access token belongs to, when it was minted
|
||||
// through the `/api/mcp/oauth` bridge. Null for manually-minted tokens and
|
||||
// native-client tokens. Lets a connection revoke kill every access row it
|
||||
// ever issued (H2), and lets the connector token list exclude the transient
|
||||
// 60-minute access rows so it does not flood (M2).
|
||||
mcpConnectionId String? @map("mcp_connection_id")
|
||||
mcpConnectionId String? @map("mcp_connection_id")
|
||||
/// Whether the HealthLog app writes the readings this `measurements:write`
|
||||
/// token carried into Apple Health (#1272). The person decides per token;
|
||||
/// the server resolves it onto every row as `appleHealthMirror`
|
||||
/// (`src/lib/measurements/apple-health-mirror.ts`). False for every other
|
||||
/// kind of token, which `PATCH /api/tokens/{id}` refuses to set.
|
||||
mirrorToAppleHealth Boolean @default(false) @map("mirror_to_apple_health")
|
||||
/// When `mirrorToAppleHealth` last changed. The keyset of the
|
||||
/// `measurementMirror` domain of `/api/sync/changes`, which re-delivers the
|
||||
/// token's rows with their new value without touching the rows themselves.
|
||||
mirrorChangedAt DateTime? @map("mirror_changed_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
mcpConnection McpOAuthConnection? @relation(fields: [mcpConnectionId], references: [id], onDelete: SetNull)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
mcpConnection McpOAuthConnection? @relation(fields: [mcpConnectionId], references: [id], onDelete: SetNull)
|
||||
ingestedMeasurements Measurement[]
|
||||
// v1.30.34 — step-up elevations bound to this token. Cascade-deleted with it.
|
||||
stepUpElevations StepUpElevation[]
|
||||
stepUpElevations StepUpElevation[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([mcpConnectionId])
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Every write that labels a measurement `EXTERNAL` also links it to the token
|
||||
* that carried it (#1272).
|
||||
*
|
||||
* The label and the link answer one question together: whether the HealthLog
|
||||
* app writes the reading into Apple Health is the choice its person made for
|
||||
* that token (`apple-health-mirror.ts`). An EXTERNAL row written without the
|
||||
* link resolves to false forever, whatever the token says, and nothing in the
|
||||
* write path would notice. So the files that resolve `EXTERNAL_SOURCE` are an
|
||||
* inventory: a measurement writer must set `ingestTokenId` at least as often
|
||||
* as it assigns the label, and a file that starts using the constant fails
|
||||
* here until it is classified.
|
||||
*
|
||||
* A tripwire, not a proof: it counts, it does not trace data flow. The
|
||||
* behaviour is pinned by `tests/integration/apple-health-mirror.test.ts`.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
readSourceText,
|
||||
stripComments,
|
||||
walkSourceFiles,
|
||||
} from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
/** Files that write measurement rows labelled with the constant. */
|
||||
const MEASUREMENT_WRITERS = [
|
||||
"lib/measurements/batch-ingest/prepare.ts",
|
||||
"lib/measurements/create/create-readings.ts",
|
||||
"lib/measurements/create/create-sleep-stage.ts",
|
||||
];
|
||||
|
||||
/** Files that use the constant for something other than a measurement write. */
|
||||
const NOT_MEASUREMENT_WRITES: Record<string, string> = {
|
||||
"app/api/tokens/measurements/route.ts":
|
||||
"counts the readings that belong to no token (a read)",
|
||||
"lib/validations/measurement.ts": "the source enum and the edit allowlist",
|
||||
"lib/workouts/batch-ingest/entries.ts":
|
||||
"labels workout rows, which carry no token link",
|
||||
};
|
||||
|
||||
function usesOfConstant(rel: string): {
|
||||
assignments: number;
|
||||
links: number;
|
||||
} {
|
||||
const code = stripComments(readSourceText(join(SRC, rel)));
|
||||
const assignments = (code.match(/\?\s*EXTERNAL_SOURCE\b/g) ?? []).length;
|
||||
// A value written under the key, or the shorthand. Not a type member
|
||||
// (`ingestTokenId: string | null`) and not a read (`writer.ingestTokenId`),
|
||||
// which write nothing.
|
||||
const links = (
|
||||
code.match(/(?<![.\w])ingestTokenId(?:\s*:(?!\s*string\b)|\s*[,}])/g) ?? []
|
||||
).length;
|
||||
return { assignments, links };
|
||||
}
|
||||
|
||||
describe("an EXTERNAL measurement carries its token", () => {
|
||||
const files = walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((p) => !p.startsWith("generated/"))
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !/\.test\.tsx?$/.test(p))
|
||||
.filter((p) => p !== "lib/measurements/external-source.ts")
|
||||
.filter((p) =>
|
||||
/\bEXTERNAL_SOURCE\b/.test(stripComments(readSourceText(join(SRC, p)))),
|
||||
);
|
||||
|
||||
it("knows every file that uses the constant", () => {
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
expect(files).toEqual(
|
||||
[...MEASUREMENT_WRITERS, ...Object.keys(NOT_MEASUREMENT_WRITES)].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(MEASUREMENT_WRITERS)(
|
||||
"%s links the token wherever it assigns the label",
|
||||
(rel) => {
|
||||
const { assignments, links } = usesOfConstant(rel);
|
||||
expect(assignments).toBeGreaterThan(0);
|
||||
expect(links).toBeGreaterThanOrEqual(assignments);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -17,9 +17,13 @@ import {
|
||||
validateMeasurementRange,
|
||||
} from "@/lib/validations/measurement";
|
||||
import { encryptNote, 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 { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { Prisma, type MeasurementType } from "@/generated/prisma/client";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -37,6 +41,7 @@ export const GET = apiHandler(
|
||||
// is not part of a unique index.
|
||||
const measurement = await prisma.measurement.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
include: APPLE_HEALTH_MIRROR_INCLUDE,
|
||||
});
|
||||
|
||||
if (!measurement || measurement.userId !== user.id) {
|
||||
@@ -51,7 +56,9 @@ export const GET = apiHandler(
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(shapeMeasurementNotes(measurement));
|
||||
return apiSuccess(
|
||||
withAppleHealthMirror(shapeMeasurementNotes(measurement)),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -113,62 +120,8 @@ export const PUT = apiHandler(
|
||||
// implausible edited value flowed unchecked into rollups, the health
|
||||
// score, the BP gates and the Coach snapshot.
|
||||
if (data.value !== undefined && data.value !== existing.value) {
|
||||
// Server-owned rows first: a value attributed to a connector / import /
|
||||
// computed engine is the provider's reading — editing the number would
|
||||
// forge a source-attributed row the server never received. Timestamp
|
||||
// and note edits stay allowed — annotating a Withings reading is
|
||||
// legitimate.
|
||||
//
|
||||
// v1.38.x — gated on `USER_CORRECTABLE_MEASUREMENT_SOURCES` rather than
|
||||
// the write-side `WRITABLE_MEASUREMENT_SOURCES` the two once shared,
|
||||
// because `EXTERNAL` separates the questions. A client may not NAME that
|
||||
// source (it is resolved from the ingest credential, and the write
|
||||
// allowlist is published as `ingest.writeAllowlist`), but the hardware
|
||||
// behind the token is the user's own scale, so the rationale above —
|
||||
// "the value is the provider's" — does not reach it. Locking those rows
|
||||
// would also break the correctability the settings card and the Home
|
||||
// Assistant guide promise. This is the only site that reads the wider
|
||||
// set; re-merging the two constants silently value-locks bridge rows.
|
||||
if (
|
||||
!(USER_CORRECTABLE_MEASUREMENT_SOURCES as readonly string[]).includes(
|
||||
existing.source,
|
||||
)
|
||||
) {
|
||||
annotate({
|
||||
action: { name: "measurements.update.server-owned-source" },
|
||||
meta: { measurement_id: id, source: existing.source },
|
||||
});
|
||||
return apiError(
|
||||
"Values from a connected source cannot be edited",
|
||||
409,
|
||||
{ errorCode: "measurement.update.server_owned_source" },
|
||||
);
|
||||
}
|
||||
|
||||
// Range check against the row's OWN type (the edit body carries no
|
||||
// type). Returned through the standard multi-issue 422 envelope so the
|
||||
// edit sheet renders it like any other field error.
|
||||
const rangeCheck = z
|
||||
.object({
|
||||
value: z.number().superRefine((value, ctx) => {
|
||||
const rangeError = validateMeasurementRange(existing.type, value);
|
||||
if (rangeError) {
|
||||
ctx.addIssue({ code: "custom", message: rangeError });
|
||||
}
|
||||
}),
|
||||
})
|
||||
.safeParse({ value: data.value });
|
||||
if (!rangeCheck.success) {
|
||||
annotate({
|
||||
action: { name: "measurements.update.validation-failed" },
|
||||
meta: {
|
||||
issue_count: rangeCheck.error.issues.length,
|
||||
measurement_id: id,
|
||||
reason: "value_out_of_range",
|
||||
},
|
||||
});
|
||||
return returnAllZodIssues(rangeCheck.error, 422);
|
||||
}
|
||||
const refused = refuseValueEdit(existing, data.value, id);
|
||||
if (refused) return refused;
|
||||
}
|
||||
|
||||
let measurement;
|
||||
@@ -185,6 +138,7 @@ export const PUT = apiHandler(
|
||||
notesEncrypted: encryptNote(data.notes),
|
||||
}),
|
||||
},
|
||||
include: APPLE_HEALTH_MIRROR_INCLUDE,
|
||||
});
|
||||
} catch (err) {
|
||||
// v1.4.28 — re-pointing `measuredAt` onto an existing
|
||||
@@ -252,7 +206,9 @@ export const PUT = apiHandler(
|
||||
{ source: "manual" },
|
||||
);
|
||||
|
||||
return apiSuccess(shapeMeasurementNotes(measurement));
|
||||
return apiSuccess(
|
||||
withAppleHealthMirror(shapeMeasurementNotes(measurement)),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -320,3 +276,69 @@ export const DELETE = apiHandler(
|
||||
return apiSuccess({ deleted: true });
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether a changed value may be written over `existing`, as the refusal to
|
||||
* answer when it may not; null when it may.
|
||||
*/
|
||||
function refuseValueEdit(
|
||||
existing: { source: string; type: MeasurementType },
|
||||
value: number,
|
||||
id: string,
|
||||
): Response | null {
|
||||
// Server-owned rows first: a value attributed to a connector / import /
|
||||
// computed engine is the provider's reading — editing the number would
|
||||
// forge a source-attributed row the server never received. Timestamp
|
||||
// and note edits stay allowed — annotating a Withings reading is
|
||||
// legitimate.
|
||||
//
|
||||
// v1.38.x — gated on `USER_CORRECTABLE_MEASUREMENT_SOURCES` rather than
|
||||
// the write-side `WRITABLE_MEASUREMENT_SOURCES` the two once shared,
|
||||
// because `EXTERNAL` separates the questions. A client may not NAME that
|
||||
// source (it is resolved from the ingest credential, and the write
|
||||
// allowlist is published as `ingest.writeAllowlist`), but the hardware
|
||||
// behind the token is the user's own scale, so the rationale above —
|
||||
// "the value is the provider's" — does not reach it. Locking those rows
|
||||
// would also break the correctability the settings card and the Home
|
||||
// Assistant guide promise. This is the only site that reads the wider
|
||||
// set; re-merging the two constants silently value-locks bridge rows.
|
||||
if (
|
||||
!(USER_CORRECTABLE_MEASUREMENT_SOURCES as readonly string[]).includes(
|
||||
existing.source,
|
||||
)
|
||||
) {
|
||||
annotate({
|
||||
action: { name: "measurements.update.server-owned-source" },
|
||||
meta: { measurement_id: id, source: existing.source },
|
||||
});
|
||||
return apiError("Values from a connected source cannot be edited", 409, {
|
||||
errorCode: "measurement.update.server_owned_source",
|
||||
});
|
||||
}
|
||||
|
||||
// Range check against the row's OWN type (the edit body carries no
|
||||
// type). Returned through the standard multi-issue 422 envelope so the
|
||||
// edit sheet renders it like any other field error.
|
||||
const rangeCheck = z
|
||||
.object({
|
||||
value: z.number().superRefine((value, ctx) => {
|
||||
const rangeError = validateMeasurementRange(existing.type, value);
|
||||
if (rangeError) {
|
||||
ctx.addIssue({ code: "custom", message: rangeError });
|
||||
}
|
||||
}),
|
||||
})
|
||||
.safeParse({ value: value });
|
||||
if (!rangeCheck.success) {
|
||||
annotate({
|
||||
action: { name: "measurements.update.validation-failed" },
|
||||
meta: {
|
||||
issue_count: rangeCheck.error.issues.length,
|
||||
measurement_id: id,
|
||||
reason: "value_out_of_range",
|
||||
},
|
||||
});
|
||||
return returnAllZodIssues(rangeCheck.error, 422);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -261,6 +261,8 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
const result = await ingestMeasurementBatch({
|
||||
user: { id: user.id, timezone: user.timezone },
|
||||
scoped,
|
||||
// On the Bearer path the token's id rides `session.id`.
|
||||
ingestTokenId: scoped ? auth.session.id : null,
|
||||
entries,
|
||||
syncTrigger: parsed.data.syncTrigger,
|
||||
ipAddress: getClientIp(request),
|
||||
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
} from "@/lib/validations/measurement";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { shapeMeasurementNotes } from "@/lib/crypto/note-cipher";
|
||||
import {
|
||||
type MeasurementWithMirrorInput,
|
||||
withAppleHealthMirror,
|
||||
} from "@/lib/measurements/apple-health-mirror";
|
||||
import { measurementListResponse } from "@/lib/measurements/list/measurement-list";
|
||||
import {
|
||||
createReading,
|
||||
@@ -154,6 +158,8 @@ async function postMeasurement(request: NextRequest) {
|
||||
const writer: ReadingWriter = {
|
||||
user: { id: user.id, timezone: user.timezone },
|
||||
scoped,
|
||||
// On the Bearer path the token's id rides `session.id`.
|
||||
ingestTokenId: scoped ? auth.session.id : null,
|
||||
ipAddress: getClientIp(request),
|
||||
};
|
||||
|
||||
@@ -202,7 +208,7 @@ async function postMeasurement(request: NextRequest) {
|
||||
if ("duplicate" in created) {
|
||||
return apiError(DUPLICATE_MESSAGE, 409, DUPLICATE_META);
|
||||
}
|
||||
return apiSuccess(created.rows.map(shapeMeasurementNotes), 201);
|
||||
return apiSuccess(created.rows.map(answerRow), 201);
|
||||
}
|
||||
|
||||
// Single mode (existing behavior)
|
||||
@@ -237,7 +243,12 @@ async function postMeasurement(request: NextRequest) {
|
||||
if ("duplicate" in created) {
|
||||
return apiError(DUPLICATE_MESSAGE, 409, DUPLICATE_META);
|
||||
}
|
||||
return apiSuccess(shapeMeasurementNotes(created.row), 201);
|
||||
return apiSuccess(answerRow(created.row), 201);
|
||||
}
|
||||
|
||||
/** A written row as it is answered: the note readable, the write-back resolved. */
|
||||
function answerRow(row: MeasurementWithMirrorInput) {
|
||||
return withAppleHealthMirror(shapeMeasurementNotes(row));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -262,6 +273,7 @@ async function postNamedSleepStage(
|
||||
{
|
||||
userId: writer.user.id,
|
||||
scoped: writer.scoped,
|
||||
ingestTokenId: writer.ingestTokenId,
|
||||
ipAddress: writer.ipAddress,
|
||||
},
|
||||
parsed.data,
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -194,6 +194,7 @@
|
||||
"sleepStage": null,
|
||||
"rhythmClassification": null,
|
||||
"deviceType": null,
|
||||
"ingestTokenId": null,
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:03.000Z",
|
||||
@@ -217,6 +218,7 @@
|
||||
"sleepStage": "DEEP",
|
||||
"rhythmClassification": null,
|
||||
"deviceType": null,
|
||||
"ingestTokenId": null,
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:04.000Z",
|
||||
@@ -240,6 +242,7 @@
|
||||
"sleepStage": null,
|
||||
"rhythmClassification": null,
|
||||
"deviceType": null,
|
||||
"ingestTokenId": null,
|
||||
"syncVersion": 4,
|
||||
"deletedAt": "<tombstone>",
|
||||
"createdAt": "2026-05-01T08:20:01.000Z",
|
||||
@@ -263,6 +266,7 @@
|
||||
"sleepStage": null,
|
||||
"rhythmClassification": null,
|
||||
"deviceType": "rt-Measurement-deviceType",
|
||||
"ingestTokenId": null,
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:00.000Z",
|
||||
@@ -286,6 +290,7 @@
|
||||
"sleepStage": null,
|
||||
"rhythmClassification": "IRREGULAR",
|
||||
"deviceType": null,
|
||||
"ingestTokenId": null,
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:02.000Z",
|
||||
@@ -2629,6 +2634,7 @@
|
||||
"sleepStage": null,
|
||||
"rhythmClassification": null,
|
||||
"deviceType": null,
|
||||
"ingestTokenId": null,
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:03.000Z",
|
||||
@@ -2652,6 +2658,7 @@
|
||||
"sleepStage": "DEEP",
|
||||
"rhythmClassification": null,
|
||||
"deviceType": null,
|
||||
"ingestTokenId": null,
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:04.000Z",
|
||||
@@ -2675,6 +2682,7 @@
|
||||
"sleepStage": null,
|
||||
"rhythmClassification": null,
|
||||
"deviceType": null,
|
||||
"ingestTokenId": null,
|
||||
"syncVersion": 4,
|
||||
"deletedAt": "<tombstone>",
|
||||
"createdAt": "2026-05-01T08:20:01.000Z",
|
||||
@@ -2698,6 +2706,7 @@
|
||||
"sleepStage": null,
|
||||
"rhythmClassification": null,
|
||||
"deviceType": "rt-Measurement-deviceType",
|
||||
"ingestTokenId": null,
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:00.000Z",
|
||||
@@ -2721,6 +2730,7 @@
|
||||
"sleepStage": null,
|
||||
"rhythmClassification": "IRREGULAR",
|
||||
"deviceType": null,
|
||||
"ingestTokenId": null,
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:02.000Z",
|
||||
|
||||
@@ -428,7 +428,8 @@
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:03.000Z",
|
||||
"updatedAt": "2026-05-01T08:21:03.000Z"
|
||||
"updatedAt": "2026-05-01T08:21:03.000Z",
|
||||
"ingestTokenId": null
|
||||
},
|
||||
{
|
||||
"id": "<id:2>",
|
||||
@@ -452,7 +453,8 @@
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:02.000Z",
|
||||
"updatedAt": "2026-05-01T08:21:02.000Z"
|
||||
"updatedAt": "2026-05-01T08:21:02.000Z",
|
||||
"ingestTokenId": null
|
||||
},
|
||||
{
|
||||
"id": "<id:3>",
|
||||
@@ -476,7 +478,8 @@
|
||||
"syncVersion": 4,
|
||||
"deletedAt": "<tombstone>",
|
||||
"createdAt": "2026-05-01T08:20:01.000Z",
|
||||
"updatedAt": "2026-05-01T08:21:01.000Z"
|
||||
"updatedAt": "2026-05-01T08:21:01.000Z",
|
||||
"ingestTokenId": null
|
||||
},
|
||||
{
|
||||
"id": "<id:4>",
|
||||
@@ -500,7 +503,8 @@
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:04.000Z",
|
||||
"updatedAt": "2026-05-01T08:21:04.000Z"
|
||||
"updatedAt": "2026-05-01T08:21:04.000Z",
|
||||
"ingestTokenId": null
|
||||
},
|
||||
{
|
||||
"id": "<id:5>",
|
||||
@@ -524,7 +528,8 @@
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:00.000Z",
|
||||
"updatedAt": "2026-05-01T08:21:00.000Z"
|
||||
"updatedAt": "2026-05-01T08:21:00.000Z",
|
||||
"ingestTokenId": null
|
||||
}
|
||||
],
|
||||
"IntradayCumulativeProfile": [
|
||||
@@ -3091,6 +3096,7 @@
|
||||
"sleepStage": null,
|
||||
"rhythmClassification": null,
|
||||
"deviceType": null,
|
||||
"ingestTokenId": null,
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:03.000Z",
|
||||
@@ -3114,6 +3120,7 @@
|
||||
"sleepStage": "DEEP",
|
||||
"rhythmClassification": null,
|
||||
"deviceType": null,
|
||||
"ingestTokenId": null,
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:04.000Z",
|
||||
@@ -3137,6 +3144,7 @@
|
||||
"sleepStage": null,
|
||||
"rhythmClassification": null,
|
||||
"deviceType": null,
|
||||
"ingestTokenId": null,
|
||||
"syncVersion": 4,
|
||||
"deletedAt": "<tombstone>",
|
||||
"createdAt": "2026-05-01T08:20:01.000Z",
|
||||
@@ -3160,6 +3168,7 @@
|
||||
"sleepStage": null,
|
||||
"rhythmClassification": null,
|
||||
"deviceType": "rt-Measurement-deviceType",
|
||||
"ingestTokenId": null,
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:00.000Z",
|
||||
@@ -3183,6 +3192,7 @@
|
||||
"sleepStage": null,
|
||||
"rhythmClassification": "IRREGULAR",
|
||||
"deviceType": null,
|
||||
"ingestTokenId": null,
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:02.000Z",
|
||||
|
||||
@@ -520,7 +520,8 @@
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "<now>",
|
||||
"updatedAt": "<now>"
|
||||
"updatedAt": "<now>",
|
||||
"ingestTokenId": null
|
||||
},
|
||||
{
|
||||
"id": "<id:2>",
|
||||
@@ -544,7 +545,8 @@
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "<now>",
|
||||
"updatedAt": "<now>"
|
||||
"updatedAt": "<now>",
|
||||
"ingestTokenId": null
|
||||
},
|
||||
{
|
||||
"id": "<id:3>",
|
||||
@@ -568,7 +570,8 @@
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "<now>",
|
||||
"updatedAt": "<now>"
|
||||
"updatedAt": "<now>",
|
||||
"ingestTokenId": null
|
||||
},
|
||||
{
|
||||
"id": "<id:4>",
|
||||
@@ -592,7 +595,8 @@
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "<now>",
|
||||
"updatedAt": "<now>"
|
||||
"updatedAt": "<now>",
|
||||
"ingestTokenId": null
|
||||
}
|
||||
],
|
||||
"IntradayCumulativeProfile": [
|
||||
|
||||
@@ -550,7 +550,8 @@
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "<now>",
|
||||
"updatedAt": "<now>"
|
||||
"updatedAt": "<now>",
|
||||
"ingestTokenId": null
|
||||
}
|
||||
],
|
||||
"IntradayCumulativeProfile": [],
|
||||
|
||||
@@ -426,7 +426,8 @@
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "<now>",
|
||||
"updatedAt": "<now>"
|
||||
"updatedAt": "<now>",
|
||||
"ingestTokenId": null
|
||||
},
|
||||
{
|
||||
"id": "<id:2>",
|
||||
@@ -450,7 +451,8 @@
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "<now>",
|
||||
"updatedAt": "<now>"
|
||||
"updatedAt": "<now>",
|
||||
"ingestTokenId": null
|
||||
},
|
||||
{
|
||||
"id": "<id:3>",
|
||||
@@ -474,7 +476,8 @@
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "<now>",
|
||||
"updatedAt": "<now>"
|
||||
"updatedAt": "<now>",
|
||||
"ingestTokenId": null
|
||||
},
|
||||
{
|
||||
"id": "<id:4>",
|
||||
@@ -498,7 +501,8 @@
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "<now>",
|
||||
"updatedAt": "<now>"
|
||||
"updatedAt": "<now>",
|
||||
"ingestTokenId": null
|
||||
}
|
||||
],
|
||||
"IntradayCumulativeProfile": [],
|
||||
@@ -1289,6 +1293,7 @@
|
||||
"sleepStage": null,
|
||||
"rhythmClassification": null,
|
||||
"deviceType": null,
|
||||
"ingestTokenId": null,
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "<now>",
|
||||
@@ -1312,6 +1317,7 @@
|
||||
"sleepStage": null,
|
||||
"rhythmClassification": null,
|
||||
"deviceType": null,
|
||||
"ingestTokenId": null,
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "<now>",
|
||||
@@ -1335,6 +1341,7 @@
|
||||
"sleepStage": null,
|
||||
"rhythmClassification": null,
|
||||
"deviceType": null,
|
||||
"ingestTokenId": null,
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "<now>",
|
||||
@@ -1358,6 +1365,7 @@
|
||||
"sleepStage": null,
|
||||
"rhythmClassification": null,
|
||||
"deviceType": null,
|
||||
"ingestTokenId": null,
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "<now>",
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:03.000Z",
|
||||
"updatedAt": "2026-05-01T08:21:03.000Z"
|
||||
"updatedAt": "2026-05-01T08:21:03.000Z",
|
||||
"ingestTokenId": null
|
||||
},
|
||||
{
|
||||
"id": "<id:2>",
|
||||
@@ -50,7 +51,8 @@
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:02.000Z",
|
||||
"updatedAt": "2026-05-01T08:21:02.000Z"
|
||||
"updatedAt": "2026-05-01T08:21:02.000Z",
|
||||
"ingestTokenId": null
|
||||
},
|
||||
{
|
||||
"id": "<id:3>",
|
||||
@@ -74,7 +76,8 @@
|
||||
"syncVersion": 4,
|
||||
"deletedAt": "<tombstone>",
|
||||
"createdAt": "2026-05-01T08:20:01.000Z",
|
||||
"updatedAt": "2026-05-01T08:21:01.000Z"
|
||||
"updatedAt": "2026-05-01T08:21:01.000Z",
|
||||
"ingestTokenId": null
|
||||
},
|
||||
{
|
||||
"id": "<id:4>",
|
||||
@@ -98,7 +101,8 @@
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:04.000Z",
|
||||
"updatedAt": "2026-05-01T08:21:04.000Z"
|
||||
"updatedAt": "2026-05-01T08:21:04.000Z",
|
||||
"ingestTokenId": null
|
||||
},
|
||||
{
|
||||
"id": "<id:5>",
|
||||
@@ -122,7 +126,8 @@
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:00.000Z",
|
||||
"updatedAt": "2026-05-01T08:21:00.000Z"
|
||||
"updatedAt": "2026-05-01T08:21:00.000Z",
|
||||
"ingestTokenId": null
|
||||
}
|
||||
],
|
||||
"IntradayCumulativeProfile": [
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* A backup carries the token link of an EXTERNAL reading, and a restore keeps
|
||||
* it only where it still means something (#1272).
|
||||
*
|
||||
* Tokens are not part of a backup. A reading linked to a token the restoring
|
||||
* account still holds comes back linked, so its Apple Health write-back is
|
||||
* still that token's setting; a reading whose token is gone comes back with no
|
||||
* link rather than a dangling id. The file is built by the real writer.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import { parseBackupPayload } from "@/lib/validations/backup";
|
||||
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
|
||||
import { POST as RESTORE } from "./restore-job-driver";
|
||||
|
||||
import { makeToken } from "../kit/factories";
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => cookieJar.set(name, value),
|
||||
delete: (name: string) => cookieJar.delete(name),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/invalidate", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/cache/invalidate")>();
|
||||
return { ...actual, invalidateUserData: vi.fn() };
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
describe("restoring EXTERNAL readings", () => {
|
||||
it("keeps the link to a token the account holds and drops the rest", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const owner = await prisma.user.create({
|
||||
data: {
|
||||
username: "token-link-owner",
|
||||
email: "token-link-owner@example.test",
|
||||
role: "ADMIN",
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: owner.id, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
|
||||
const kept = await makeToken(owner, {
|
||||
permissions: [MEASUREMENTS_WRITE_SCOPE],
|
||||
});
|
||||
const gone = await makeToken(owner, {
|
||||
permissions: [MEASUREMENTS_WRITE_SCOPE],
|
||||
});
|
||||
const reading = (day: string, ingestTokenId: string | null) => ({
|
||||
userId: owner.id,
|
||||
type: "WEIGHT" as const,
|
||||
source: "EXTERNAL" as const,
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
measuredAt: new Date(`${day}T06:00:00.000Z`),
|
||||
ingestTokenId,
|
||||
});
|
||||
await prisma.measurement.createMany({
|
||||
data: [
|
||||
reading("2026-10-01", kept.tokenId),
|
||||
reading("2026-10-02", gone.tokenId),
|
||||
reading("2026-10-03", null),
|
||||
],
|
||||
});
|
||||
|
||||
const { payload: built } = await buildFullBackupPayload(prisma, owner.id, {
|
||||
purpose: "disaster-recovery",
|
||||
});
|
||||
const parsed = parseBackupPayload(JSON.stringify(built));
|
||||
const backup = await prisma.dataBackup.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
type: "TOKEN_LINK_ROUND_TRIP",
|
||||
data: encrypt(JSON.stringify(parsed)),
|
||||
},
|
||||
});
|
||||
await prisma.measurement.deleteMany({ where: { userId: owner.id } });
|
||||
await prisma.apiToken.delete({ where: { id: gone.tokenId } });
|
||||
|
||||
const response = await RESTORE(
|
||||
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "RESTORE" }),
|
||||
}) as unknown as Parameters<typeof RESTORE>[0],
|
||||
{ params: Promise.resolve({ id: backup.id }) },
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const restored = await prisma.measurement.findMany({
|
||||
where: { userId: owner.id },
|
||||
select: { measuredAt: true, ingestTokenId: true },
|
||||
orderBy: { measuredAt: "asc" },
|
||||
});
|
||||
expect(restored.map((r) => r.ingestTokenId)).toEqual([
|
||||
kept.tokenId,
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true
|
||||
},
|
||||
{
|
||||
"id": "<id:3>",
|
||||
@@ -47,7 +48,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": false
|
||||
},
|
||||
{
|
||||
"id": "<id:4>",
|
||||
@@ -70,7 +72,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": false
|
||||
},
|
||||
{
|
||||
"id": "<id:5>",
|
||||
@@ -93,7 +96,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true
|
||||
},
|
||||
{
|
||||
"id": "<id:6>",
|
||||
@@ -116,7 +120,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true
|
||||
},
|
||||
{
|
||||
"id": "<id:7>",
|
||||
@@ -139,7 +144,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true
|
||||
},
|
||||
{
|
||||
"id": "<id:8>",
|
||||
@@ -162,7 +168,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true
|
||||
},
|
||||
{
|
||||
"id": "<id:9>",
|
||||
@@ -185,7 +192,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true
|
||||
},
|
||||
{
|
||||
"id": "<id:10>",
|
||||
@@ -208,7 +216,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true
|
||||
},
|
||||
{
|
||||
"id": "<id:11>",
|
||||
@@ -231,7 +240,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": false
|
||||
},
|
||||
{
|
||||
"id": "<id:12>",
|
||||
@@ -254,7 +264,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": false
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
|
||||
+4
-2
@@ -24,7 +24,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true
|
||||
},
|
||||
{
|
||||
"id": "<id:3>",
|
||||
@@ -47,7 +48,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
|
||||
+4
-2
@@ -24,7 +24,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true
|
||||
},
|
||||
{
|
||||
"id": "<id:3>",
|
||||
@@ -47,7 +48,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
|
||||
+2
-1
@@ -24,7 +24,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": false
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true
|
||||
},
|
||||
"error": null
|
||||
},
|
||||
@@ -73,7 +74,9 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"ingestTokenId": null,
|
||||
"ingestToken": null
|
||||
}
|
||||
],
|
||||
"manual"
|
||||
|
||||
+5
-2
@@ -22,7 +22,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true
|
||||
},
|
||||
"error": null
|
||||
},
|
||||
@@ -73,7 +74,9 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"ingestTokenId": null,
|
||||
"ingestToken": null
|
||||
}
|
||||
],
|
||||
"manual"
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true
|
||||
},
|
||||
{
|
||||
"id": "<id:3>",
|
||||
@@ -46,7 +47,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true
|
||||
},
|
||||
{
|
||||
"id": "<id:4>",
|
||||
@@ -69,7 +71,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true
|
||||
}
|
||||
],
|
||||
"error": null
|
||||
@@ -126,7 +129,9 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"ingestTokenId": null,
|
||||
"ingestToken": null
|
||||
},
|
||||
{
|
||||
"id": "<id:3>",
|
||||
@@ -150,7 +155,9 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"ingestTokenId": null,
|
||||
"ingestToken": null
|
||||
},
|
||||
{
|
||||
"id": "<id:4>",
|
||||
@@ -174,7 +181,9 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"ingestTokenId": null,
|
||||
"ingestToken": null
|
||||
}
|
||||
],
|
||||
"manual"
|
||||
|
||||
+1
@@ -23,6 +23,7 @@
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true,
|
||||
"status": "inserted"
|
||||
},
|
||||
"error": null
|
||||
|
||||
+1
@@ -23,6 +23,7 @@
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true,
|
||||
"status": "duplicate"
|
||||
},
|
||||
"error": null
|
||||
|
||||
+1
@@ -23,6 +23,7 @@
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true,
|
||||
"status": "duplicate"
|
||||
},
|
||||
"error": null
|
||||
|
||||
+13
-8
@@ -22,7 +22,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": false
|
||||
},
|
||||
"error": null
|
||||
},
|
||||
@@ -73,7 +74,11 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"ingestTokenId": "<id:3>",
|
||||
"ingestToken": {
|
||||
"mirrorToAppleHealth": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"manual"
|
||||
@@ -111,7 +116,7 @@
|
||||
],
|
||||
"stored": [
|
||||
{
|
||||
"id": "<id:3>",
|
||||
"id": "<id:4>",
|
||||
"type": "WEIGHT",
|
||||
"value": 80.4,
|
||||
"unit": "kg",
|
||||
@@ -141,7 +146,7 @@
|
||||
"sealedNote": false
|
||||
},
|
||||
{
|
||||
"id": "<id:4>",
|
||||
"id": "<id:5>",
|
||||
"type": "BLOOD_PRESSURE_SYS",
|
||||
"value": 128,
|
||||
"unit": "mmHg",
|
||||
@@ -156,7 +161,7 @@
|
||||
"sealedNote": false
|
||||
},
|
||||
{
|
||||
"id": "<id:5>",
|
||||
"id": "<id:6>",
|
||||
"type": "BLOOD_PRESSURE_DIA",
|
||||
"value": 82,
|
||||
"unit": "mmHg",
|
||||
@@ -171,7 +176,7 @@
|
||||
"sealedNote": false
|
||||
},
|
||||
{
|
||||
"id": "<id:6>",
|
||||
"id": "<id:7>",
|
||||
"type": "PULSE",
|
||||
"value": 66,
|
||||
"unit": "bpm",
|
||||
@@ -186,7 +191,7 @@
|
||||
"sealedNote": true
|
||||
},
|
||||
{
|
||||
"id": "<id:7>",
|
||||
"id": "<id:8>",
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 90,
|
||||
"unit": "minutes",
|
||||
@@ -201,7 +206,7 @@
|
||||
"sealedNote": false
|
||||
},
|
||||
{
|
||||
"id": "<id:8>",
|
||||
"id": "<id:9>",
|
||||
"type": "BLOOD_GLUCOSE",
|
||||
"value": 104,
|
||||
"unit": "mg/dL",
|
||||
|
||||
+5
-2
@@ -22,7 +22,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true
|
||||
},
|
||||
"error": null
|
||||
},
|
||||
@@ -73,7 +74,9 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"ingestTokenId": null,
|
||||
"ingestToken": null
|
||||
}
|
||||
],
|
||||
"manual"
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Migration 0476 links existing EXTERNAL readings to the token that carried
|
||||
* them only where that is provable (#1272): the account holds exactly one
|
||||
* measurement token, revoked ones counted, and the reading is no older than
|
||||
* the token. Everything else stays unlinked. The pass writes nothing but the
|
||||
* link, so no reading's `updatedAt` or `syncVersion` moves, and a rerun
|
||||
* changes nothing.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
|
||||
|
||||
import { makeToken, makeUser } from "../kit/factories";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
const MIGRATION = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
"prisma",
|
||||
"migrations",
|
||||
"0476_token_apple_health_mirror",
|
||||
"migration.sql",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
/** The file is three `DO` blocks; each goes through as its own statement. */
|
||||
async function runMigration(): Promise<void> {
|
||||
const blocks = MIGRATION.match(/DO \$migration\$[\s\S]*?\$migration\$;/g);
|
||||
expect(blocks).toHaveLength(3);
|
||||
for (const block of blocks!) await prisma.$executeRawUnsafe(block);
|
||||
}
|
||||
|
||||
const TOKEN_AT = new Date("2026-06-01T00:00:00.000Z");
|
||||
|
||||
async function externalReading(
|
||||
userId: string,
|
||||
createdAt: Date,
|
||||
source: "EXTERNAL" | "MANUAL" = "EXTERNAL",
|
||||
) {
|
||||
return prisma.measurement.create({
|
||||
data: {
|
||||
userId,
|
||||
type: "WEIGHT",
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
source,
|
||||
measuredAt: createdAt,
|
||||
createdAt,
|
||||
updatedAt: new Date("2026-07-01T00:00:00.000Z"),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
|
||||
async function measurementToken(userId: string, revoked = false) {
|
||||
const token = await makeToken(
|
||||
{ id: userId },
|
||||
{ permissions: [MEASUREMENTS_WRITE_SCOPE] },
|
||||
);
|
||||
await prisma.apiToken.update({
|
||||
where: { id: token.tokenId },
|
||||
data: { createdAt: TOKEN_AT, revoked },
|
||||
});
|
||||
return token.tokenId;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
});
|
||||
|
||||
describe("migration 0476 backfill", () => {
|
||||
it("links only what one token provably carried", async () => {
|
||||
const sole = await makeUser({ timezone: "UTC" });
|
||||
const soleToken = await measurementToken(sole.id, true);
|
||||
// A wildcard sign-in token does not make the measurement token ambiguous.
|
||||
await makeToken(sole, { permissions: ["*"] });
|
||||
const after = await externalReading(
|
||||
sole.id,
|
||||
new Date("2026-06-02T08:00:00.000Z"),
|
||||
);
|
||||
const before = await externalReading(
|
||||
sole.id,
|
||||
new Date("2026-05-30T08:00:00.000Z"),
|
||||
);
|
||||
const manual = await externalReading(
|
||||
sole.id,
|
||||
new Date("2026-06-03T08:00:00.000Z"),
|
||||
"MANUAL",
|
||||
);
|
||||
|
||||
const two = await makeUser({ timezone: "UTC" });
|
||||
await measurementToken(two.id);
|
||||
await measurementToken(two.id);
|
||||
const ambiguous = await externalReading(
|
||||
two.id,
|
||||
new Date("2026-06-02T08:00:00.000Z"),
|
||||
);
|
||||
|
||||
const stamps = await prisma.measurement.findMany({
|
||||
select: { id: true, updatedAt: true, syncVersion: true },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
|
||||
await runMigration();
|
||||
await runMigration();
|
||||
|
||||
const links = Object.fromEntries(
|
||||
(
|
||||
await prisma.measurement.findMany({
|
||||
select: { id: true, ingestTokenId: true },
|
||||
})
|
||||
).map((r) => [r.id, r.ingestTokenId]),
|
||||
);
|
||||
expect(links).toEqual({
|
||||
[after.id]: soleToken,
|
||||
[before.id]: null,
|
||||
[manual.id]: null,
|
||||
[ambiguous.id]: null,
|
||||
});
|
||||
expect(
|
||||
await prisma.measurement.findMany({
|
||||
select: { id: true, updatedAt: true, syncVersion: true },
|
||||
orderBy: { id: "asc" },
|
||||
}),
|
||||
).toEqual(stamps);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user