feat(db): add the v1.43 schema and drop the readable location columns

Migrations 0402 to 0410: AI usage levels, biomarker aliases and merges,
medication ingredients (with the supplement label run kind), life
circumstance fact kinds, AI sections and AUDIT-C, Dawarich context, the
pulse day-mean method, intraday hour buckets, the readable location drop
and five relation link tables. Every new column carries its reader or a
pending-consumer marker.

0409 refuses to run while any home, location period or stored day still
holds a readable location, and names the counts. Locations are read from
the sealed copy only; the encryption backfill no longer handles them, and
the demo seed writes sealed locations only.

The new tables are backed up and wiped, the new sealed columns are in the
rotation registry with labels of their own, the AI usage level and the
life-circumstance confirmations travel with the backup, and the wellbeing
score reads the mental-health screeners only.
This commit is contained in:
Marc-André Bombeck committed 2026-10-15 21:48:57 +02:00
1 parent 0b64b80343
commit 6e72de1c35
45 files changed
+1386 -917

No files matched your search

@@ -43,13 +43,7 @@ const EXCLUDED: Record<string, string> = {
};
/** Columns written through the location helper rather than `row.<column>`. */
const LOCATION_COLUMNS = new Set([
"lat",
"lon",
"locationLabel",
"label",
"locationEncrypted",
]);
const LOCATION_COLUMNS = new Set(["locationEncrypted"]);
function block(text: string, start: string, end = "\n}"): string {
const from = text.indexOf(start);
@@ -95,9 +89,10 @@ describe.each([
const selected = selectKeys(selectName);
it("parses both ends into a plausible shape", () => {
expect(columns.length).toBeGreaterThan(8);
// A period is seven columns since v1.43 (the readable location dropped).
expect(columns.length).toBeGreaterThan(6);
expect(columns).toContain("locationEncrypted");
expect(selected.length).toBeGreaterThan(5);
expect(selected.length).toBeGreaterThan(4);
});
it("the backup select covers every column", () => {
@@ -61,7 +61,6 @@ vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(async () => ({
homeLat: null,
homeLocationEncrypted: new Uint8Array([1]),
homeSince: new Date("2025-01-01T00:00:00Z"),
})),
+2 -4
View File
@@ -63,11 +63,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
const profile = await prisma.user.findUnique({
where: { id: user.id },
select: { homeLat: true, homeLocationEncrypted: true, homeSince: true },
select: { homeLocationEncrypted: true, homeSince: true },
});
// The sealed home (v1.42), or the readable one the encryption backfill has
// not cleared yet.
if (profile?.homeLocationEncrypted == null && profile?.homeLat == null) {
if (profile?.homeLocationEncrypted == null) {
return apiError("Set a home location before backfilling.", 409, {
errorCode: "environment.no_home",
});
-3
View File
@@ -57,9 +57,6 @@ export const PUT = apiHandler(async (request: NextRequest) => {
where: { id: user.id },
data: {
homeLocationEncrypted: sealLocation(home),
homeLat: null,
homeLon: null,
homeLabel: null,
homeTimezone: timezone,
homeSince,
},
+2 -20
View File
@@ -48,9 +48,6 @@ export const GET = apiHandler(async () => {
prisma.user.findUnique({
where: { id: user.id },
select: {
homeLat: true,
homeLon: true,
homeLabel: true,
homeLocationEncrypted: true,
homeTimezone: true,
homeSince: true,
@@ -66,9 +63,6 @@ export const GET = apiHandler(async () => {
id: true,
startDate: true,
endDate: true,
lat: true,
lon: true,
label: true,
locationEncrypted: true,
},
}),
@@ -116,14 +110,7 @@ export const GET = apiHandler(async () => {
]);
const homeLocation = profile
? readLocation({
sealed: profile.homeLocationEncrypted,
lat: profile.homeLat,
lon: profile.homeLon,
// The label may be absent on a pre-v1.42 home; the answer has
// always allowed a null label there.
label: profile.homeLabel ?? "",
})
? readLocation(profile.homeLocationEncrypted)
: null;
const home =
profile && homeLocation
@@ -139,12 +126,7 @@ export const GET = apiHandler(async () => {
: null;
const travel = travelRows.flatMap((row) => {
const location = readLocation({
sealed: row.locationEncrypted,
lat: row.lat,
lon: row.lon,
label: row.label,
});
const location = readLocation(row.locationEncrypted);
return location
? [
{
-3
View File
@@ -57,9 +57,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
startDate: entry.startDate,
endDate: entry.endDate,
locationEncrypted: sealLocation(location),
lat: null,
lon: null,
label: null,
},
select: { id: true, startDate: true, endDate: true },
});
@@ -53,7 +53,8 @@ function storedDay(date: string, extra: Record<string, unknown> = {}) {
return {
date,
source: "HOME",
// A row as the database might still hold it; none of this may leave.
// A row carrying location-shaped fields (a mocked row can, the table no
// longer does); none of this may leave.
lat: 51.5,
lon: 7.2,
locationLabel: "Bochum, Germany",
+30 -14
View File
@@ -43,6 +43,10 @@ import { computeWeightGoal } from "./weight-goal";
import { DAY_MS, uniqueSources } from "./shared";
import type { DerivedProvenanceSource } from "@/lib/insights/derived/types";
import type { GlucoseUnit } from "@/lib/glucose";
import {
SCREENER_INSTRUMENTS,
isScreenerInstrument,
} from "@/lib/validations/mental-health";
const HISTORY_OFFSET_DAYS = 14;
const SCORE_READ_DAYS = 365 + HISTORY_OFFSET_DAYS;
@@ -687,20 +691,32 @@ export async function computeUserHealthScore(
),
input.modules.mentalHealth
? capture(
db.mentalHealthAssessment.findMany({
where: {
userId: input.userId,
takenAt: { gte: since },
deletedAt: null,
},
orderBy: { takenAt: "asc" },
select: {
instrument: true,
totalScore: true,
item9Flagged: true,
takenAt: true,
},
}),
db.mentalHealthAssessment
.findMany({
where: {
userId: input.userId,
takenAt: { gte: since },
deletedAt: null,
// The wellbeing screeners only. AUDIT-C shares the table for
// its storage, but it belongs to the life circumstances, not
// to this pillar.
instrument: { in: [...SCREENER_INSTRUMENTS] },
},
orderBy: { takenAt: "asc" },
select: {
instrument: true,
totalScore: true,
item9Flagged: true,
takenAt: true,
},
})
.then((rows) =>
rows.flatMap((row) =>
isScreenerInstrument(row.instrument)
? [{ ...row, instrument: row.instrument }]
: [],
),
),
[],
"WELLBEING",
)
+35 -2
View File
@@ -68,6 +68,16 @@ export const DOCUMENT_AI_RUN_RESULT_AAD = "healthlog/document-ai-run-result/v1";
*/
export const ENVIRONMENT_LOCATION_AAD = "healthlog/environment-location/v1";
/**
* The associated-data labels for the Dawarich context (v1.43): the sealed
* connection preferences on the user row (home place id, home country,
* dismissed travel suggestions) and each stored day's country code. Labelled
* like the environment locations, each with its own label so a country code
* never opens as a location or as the preferences.
*/
export const DAWARICH_PREFS_AAD = "healthlog/dawarich-prefs/v1";
export const DAWARICH_DAY_COUNTRY_AAD = "healthlog/dawarich-day-country/v1";
export interface EncryptedColumn {
/** Prisma model name (PascalCase, as declared in schema.prisma). */
readonly model: string;
@@ -149,6 +159,9 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
{ model: "User", field: "googleHealthClientSecretEncrypted", kind: "string" },
{ model: "User", field: "nightscoutUrlEncrypted", kind: "string" },
{ model: "User", field: "nightscoutTokenEncrypted", kind: "string" },
// v1.43 — Dawarich connection (URL and API key, like the Nightscout pair).
{ model: "User", field: "dawarichUrlEncrypted", kind: "string" },
{ model: "User", field: "dawarichApiKeyEncrypted", kind: "string" },
{ model: "User", field: "polarAccessTokenEncrypted", kind: "string" },
{ model: "User", field: "polarUserIdEncrypted", kind: "string" },
{ model: "User", field: "polarClientIdEncrypted", kind: "string" },
@@ -408,8 +421,8 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
// ───── v1.42 environment locations (Bytes, binary2) ─────
// The coarse home, each dated location period, and each day's resolved
// location, sealed as (lat, lon, label) under one label. The plaintext
// columns beside them empty as the encryption backfill runs.
// location, sealed as (lat, lon, label) under one label. The only copy since
// v1.43 (migration 0409 dropped the plaintext columns).
{
model: "User",
field: "homeLocationEncrypted",
@@ -432,6 +445,25 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
aad: ENVIRONMENT_LOCATION_AAD,
},
// ───── v1.43 Dawarich context (Bytes, binary2) ─────
// The connection preferences (a place id on the Dawarich instance, the home
// country, dismissed travel suggestions) and each day's country code. Daily
// values only; no coordinate or place name is ever stored.
{
model: "User",
field: "dawarichPrefsEncrypted",
kind: "bytes",
codec: "binary2",
aad: DAWARICH_PREFS_AAD,
},
{
model: "DawarichDay",
field: "countryCodeEncrypted",
kind: "bytes",
codec: "binary2",
aad: DAWARICH_DAY_COUNTRY_AAD,
},
// ───── v1.38.0 dose free text (Bytes column) ─────
// What the person wrote about the dose — a reaction, a sore arm, why the
// schedule went the way it did. The Pass's own structured fields stay
@@ -583,6 +615,7 @@ export const UPDATED_AT_MODELS: ReadonlySet<string> = new Set([
"CoachPlan",
"CoachReminder",
"CycleDayLog",
"DawarichDay",
"DocumentContentIndex",
"DocumentSourceConnection",
"DocumentThumbnail",
+23 -3
View File
@@ -81,6 +81,9 @@ export const WIPE_MODELS = [
"MedicationCategoryLabel",
// The courses (v1.40, #1024), before the medications they cascade from.
"MedicationCourse",
// v1.43 — what one unit of a supplement contains. Cascades from the
// medication; named before it so the count reads true.
"MedicationIngredient",
// ── Mood ────────────────────────────────────────────────────────────────
// The day context, before the entry it hangs off: it cascades either way,
@@ -104,9 +107,18 @@ export const WIPE_MODELS = [
// are deleted before any of those, or their per-model counts read zero on an
// audit row that claims to say what was removed. The encounter follows its
// own links; the address book it points at is a parent and goes last.
// v1.43 adds the remaining relation links (a lab result's document, a
// visit's medications and doses, a medication's documents and conditions);
// they cascade from both ends the same way, so they go first for the same
// reason.
"EncounterDocumentLink",
"EncounterLabLink",
"EncounterConditionLink",
"EncounterMedicationLink",
"EncounterVaccinationLink",
"LabResultDocumentLink",
"MedicationDocumentLink",
"MedicationConditionLink",
"Encounter",
// ── Vaccinations ────────────────────────────────────────────────────────
@@ -124,6 +136,10 @@ export const WIPE_MODELS = [
// ── Clinical record ─────────────────────────────────────────────────────
"MentalHealthAssessment",
"LabResult",
// v1.43 — a marker's other spellings and the merges folded into it. Both
// cascade from the marker; named before it so their counts read true.
"BiomarkerAlias",
"BiomarkerMerge",
"Biomarker",
"CustomMetricEntry",
"MenstrualCycle",
@@ -150,6 +166,8 @@ export const WIPE_MODELS = [
// ── Context and engagement ──────────────────────────────────────────────
"EnvironmentContext",
"EnvironmentTravelLocation",
// v1.43 — Dawarich daily movement values. Part of the environment record.
"DawarichDay",
"HealthProfileFactRevision",
"CorrelationPattern",
"UserHealthProfile",
@@ -372,9 +390,6 @@ export const USER_RESET = {
avatarUpdatedAt: null,
// Where the person lives — an environment-context input, and a location
homeLat: null,
homeLon: null,
homeLabel: null,
homeTimezone: null,
homeSince: null,
homeLocationEncrypted: null,
@@ -406,6 +421,7 @@ export const USER_RESET = {
aiProviderChain: Prisma.DbNull,
aiResponseTimeoutSeconds: null,
disableCoach: false,
aiUsageLevel: null,
coachPrefsJson: Prisma.DbNull,
coachLastSeenAt: null,
documentsAutoAiRead: false,
@@ -432,6 +448,10 @@ export const USER_RESET = {
nightscoutUrlEncrypted: null,
nightscoutTokenEncrypted: null,
nightscoutAllowPrivateHost: false,
dawarichUrlEncrypted: null,
dawarichApiKeyEncrypted: null,
dawarichPrefsEncrypted: null,
dawarichAllowPrivateHost: false,
polarAccessTokenEncrypted: null,
polarUserIdEncrypted: null,
polarClientIdEncrypted: null,
+3 -2
View File
@@ -30,6 +30,7 @@ import {
type AiRunParams,
type AiRunResult,
type DocumentAiRunKindValue,
servedRunKind,
} from "./types";
/** The longest a client is told to wait between polls. */
@@ -113,7 +114,7 @@ export async function claimAiRun(
return {
id: row.id,
userId: row.userId,
kind: row.kind,
kind: servedRunKind(row.kind),
documentId: row.documentId,
params: row.paramsJson as unknown as AiRunParams,
input,
@@ -252,7 +253,7 @@ export async function readAiRunForUser(
: 0;
return {
id: row.id,
kind: row.kind,
kind: servedRunKind(row.kind),
documentId: row.documentId,
status: unreadable ? "FAILED" : row.status,
result,
+13
View File
@@ -25,6 +25,19 @@ export const DOCUMENT_AI_RUN_KINDS = [
] as const;
export type DocumentAiRunKindValue = (typeof DOCUMENT_AI_RUN_KINDS)[number];
/**
* A stored run's kind as one this build can serve. The database enum may
* carry a kind ahead of its worker (a value added with its migration before
* the run that uses it lands); such a row is refused here rather than handed
* to a dispatcher that has no entry for it.
*/
export function servedRunKind(kind: string): DocumentAiRunKindValue {
if ((DOCUMENT_AI_RUN_KINDS as readonly string[]).includes(kind)) {
return kind as DocumentAiRunKindValue;
}
throw new Error(`document AI run kind not served by this build: ${kind}`);
}
/**
* The capability each kind of run answers to: the one its queuing route asks,
* asked again by the worker for the record before anything leaves.
@@ -56,27 +56,23 @@ describe("sealLocation / openLocation", () => {
});
describe("readLocation", () => {
it("prefers the sealed value over readable columns", () => {
it("opens the sealed value", () => {
const sealed = sealLocation({ lat: 48.1, lon: 11.6, label: "Munich" });
expect(
readLocation({ sealed, lat: 52.5, lon: 13.4, label: "Berlin" }),
).toEqual({ lat: 48.1, lon: 11.6, label: "Munich" });
expect(readLocation(sealed)).toEqual({
lat: 48.1,
lon: 11.6,
label: "Munich",
});
});
it("falls back to the readable columns for a row the backfill has not reached", () => {
expect(
readLocation({ sealed: null, lat: 52.5, lon: 13.4, label: "Berlin" }),
).toEqual({ lat: 52.5, lon: 13.4, label: "Berlin" });
expect(
readLocation({ sealed: null, lat: 52.5, lon: null, label: "Berlin" }),
).toBeNull();
it("reads a row without a sealed value as no location", () => {
expect(readLocation(null)).toBeNull();
expect(readLocation(new Uint8Array())).toBeNull();
});
it("reads a value that does not open as no location, never as the readable columns", () => {
it("reads a value that does not open as no location", () => {
const sealed = sealLocation({ lat: 48.1, lon: 11.6, label: "Munich" });
vi.stubEnv("ENCRYPTION_KEY", "c".repeat(64));
expect(
readLocation({ sealed, lat: 52.5, lon: 13.4, label: "Berlin" }),
).toBeNull();
expect(readLocation(sealed)).toBeNull();
});
});
@@ -113,9 +113,6 @@ beforeEach(() => {
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
vi.stubEnv("ENCRYPTION_KEY", "d".repeat(64));
db.user = {
homeLat: null,
homeLon: null,
homeLabel: null,
homeLocationEncrypted: sealLocation({
lat: 51.48,
lon: 7.21,
@@ -146,9 +143,6 @@ describe("fetchAndStoreEnvironment", () => {
{
startDate: "2026-05-02",
endDate: "2026-05-03",
lat: null,
lon: null,
label: null,
locationEncrypted: damaged,
},
];
@@ -189,10 +183,10 @@ describe("fetchAndStoreEnvironment", () => {
expect([call.lat, call.lon]).toEqual([51.5, 7.2]);
}
const { create } = db.upserts[0];
// The location rides sealed only; no readable column exists to write.
expect(create).not.toHaveProperty("lat");
expect(create).not.toHaveProperty("locationLabel");
expect(create).toMatchObject({
lat: null,
lon: null,
locationLabel: null,
source: "HOME",
apparentMax: 25,
pm25Mean: 9.5,
@@ -260,9 +254,6 @@ describe("fetchAndStoreEnvironment", () => {
{
startDate: "2026-05-01",
endDate: "2026-05-02",
lat: null,
lon: null,
label: null,
locationEncrypted: sealLocation({
lat: 38.72,
lon: -9.14,
@@ -290,16 +281,10 @@ describe("fillAirQualityGaps", () => {
db.days = [
{
date: "2026-05-03",
lat: null,
lon: null,
locationLabel: null,
locationEncrypted: sealed,
},
{
date: "2026-05-02",
lat: null,
lon: null,
locationLabel: null,
locationEncrypted: sealed,
},
];
+1 -9
View File
@@ -358,9 +358,6 @@ export async function collectAirQualityHistory(
select: {
date: true,
aqFetchedAt: true,
lat: true,
lon: true,
locationLabel: true,
locationEncrypted: true,
source: true,
},
@@ -375,12 +372,7 @@ export async function collectAirQualityHistory(
const row = rowByDate.get(date);
let location: ResolvedLocation | null;
if (row) {
const stored = readLocation({
sealed: row.locationEncrypted,
lat: row.lat,
lon: row.lon,
label: row.locationLabel,
});
const stored = readLocation(row.locationEncrypted);
location = stored ? { ...stored, source: row.source } : null;
} else if (inAnyPeriod(date, travel.unreadable)) {
location = null;
+25 -42
View File
@@ -1,22 +1,21 @@
/**
* The environment module's coarse locations, sealed at rest (v1.42, #615).
*
* Three places held a location in readable columns: the home on the user row
* (`homeLat` / `homeLon` / `homeLabel`), each dated location period
* (`EnvironmentTravelLocation.lat` / `lon` / `label`), and each stored day
* (`EnvironmentContext.lat` / `lon` / `locationLabel`). Coarse as they are
* (one decimal, about 11 km), a home city and a dated list of trips are still
* a movement profile. From v1.42 each row stores ONE sealed value instead, the
* JSON `{ lat, lon, label }` under AES-256-GCM with the associated-data label
* Three places hold a location: the home on the user row
* (`homeLocationEncrypted`), each dated location period
* (`EnvironmentTravelLocation.locationEncrypted`), and each stored day
* (`EnvironmentContext.locationEncrypted`). Coarse as they are (one decimal,
* about 11 km), a home city and a dated list of trips are still a movement
* profile, so each row stores ONE sealed value, the JSON `{ lat, lon, label }`
* under AES-256-GCM with the associated-data label
* {@link ENVIRONMENT_LOCATION_AAD}: one decrypt per row, and the three parts
* can never drift apart.
*
* Writers seal through {@link sealLocation}; the readable columns are written
* as null. Readers go through {@link readLocation}: the sealed value when a
* row has one, the readable columns only for a row the boot-time encryption
* backfill (`free-text-encryption-backfill.ts`) has not reached yet. A sealed
* value that does not open reads as no location, with a warning on the
* request's event, and is never replaced by the readable columns.
* Writers seal through {@link sealLocation}; readers go through
* {@link readLocation}. A sealed value that does not open reads as no
* location, with a warning on the request's event. The readable columns the
* sealed value replaced were dropped in v1.43 (migration 0409), which refuses
* to run while any of them still held a value.
*
* The coordinates are rounded to the privacy floor BEFORE sealing, so the
* sealed value never carries more precision than the readable column did,
@@ -78,37 +77,21 @@ export function openLocation(sealed: Uint8Array): CoarseLocation {
}
/**
* The location a row holds: the sealed value first, the legacy readable
* columns only when there is no sealed value. Null when the row holds
* neither (or only part of the readable triple), and null with a warning when
* the sealed value does not open.
*
* `fallbackLabel` stands in for a missing readable label, which the home has
* always allowed (`homeLabel` was nullable from the start).
* The location a row holds, from its sealed value. Null when the row holds
* none, and null with a warning when the sealed value does not open.
*/
export function readLocation(
row: {
sealed: Uint8Array | null | undefined;
lat: number | null | undefined;
lon: number | null | undefined;
label: string | null | undefined;
},
fallbackLabel?: string,
sealed: Uint8Array | null | undefined,
): CoarseLocation | null {
if (row.sealed && row.sealed.byteLength > 0) {
try {
return openLocation(row.sealed);
} catch (err) {
getEvent()?.addWarning(
`environment location decrypt failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
return null;
}
if (!sealed || sealed.byteLength === 0) return null;
try {
return openLocation(sealed);
} catch (err) {
getEvent()?.addWarning(
`environment location decrypt failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
return null;
}
if (row.lat == null || row.lon == null) return null;
const label = row.label ?? fallbackLabel;
if (label == null) return null;
return { lat: row.lat, lon: row.lon, label };
}
+5 -37
View File
@@ -165,9 +165,6 @@ export interface FetchAndStoreResult {
/** The user columns every environment read resolves the home from. */
export const HOME_SELECT = {
homeLat: true,
homeLon: true,
homeLabel: true,
homeLocationEncrypted: true,
homeTimezone: true,
homeSince: true,
@@ -176,25 +173,16 @@ export const HOME_SELECT = {
} as const;
/**
* The account's home as the resolver uses it: the sealed copy opened, or the
* readable columns for a row the encryption backfill has not reached. Null
* The account's home as the resolver uses it: the sealed copy opened. Null
* when no home is set (or the sealed value does not open).
*/
export function resolveHome(user: {
homeLat: number | null;
homeLon: number | null;
homeLabel: string | null;
homeLocationEncrypted: Uint8Array | null;
homeTimezone: string | null;
homeSince: Date | null;
timezone: string;
}): HomeLocation | null {
const location = readLocation({
sealed: user.homeLocationEncrypted,
lat: user.homeLat,
lon: user.homeLon,
label: user.homeLabel ?? "",
});
const location = readLocation(user.homeLocationEncrypted);
if (!location) return null;
return {
...location,
@@ -229,21 +217,13 @@ export async function readTravelOverrides(userId: string): Promise<{
select: {
startDate: true,
endDate: true,
lat: true,
lon: true,
label: true,
locationEncrypted: true,
},
});
const overrides: TravelOverride[] = [];
const unreadable: UnreadablePeriod[] = [];
for (const row of records) {
const location = readLocation({
sealed: row.locationEncrypted,
lat: row.lat,
lon: row.lon,
label: row.label,
});
const location = readLocation(row.locationEncrypted);
if (location) {
overrides.push({
startDate: row.startDate,
@@ -478,12 +458,8 @@ export function buildUpsert(
? airQualityColumns(air.day, air.now)
: AIR_QUALITY_UNFETCHED;
const data = {
// The coarse location rides sealed only; the readable columns are
// written empty (they drop in a later release).
// The coarse location rides sealed only.
locationEncrypted: sealed,
lat: null,
lon: null,
locationLabel: null,
source: loc.source,
tempMin: obs.tempMin,
tempMax: obs.tempMax,
@@ -550,9 +526,6 @@ export async function fillAirQualityGaps(
take: maxDays,
select: {
date: true,
lat: true,
lon: true,
locationLabel: true,
locationEncrypted: true,
},
});
@@ -564,12 +537,7 @@ export async function fillAirQualityGaps(
{ lat: number; lon: number; days: string[] }
>();
for (const row of rows) {
const location = readLocation({
sealed: row.locationEncrypted,
lat: row.lat,
lon: row.lon,
label: row.locationLabel,
});
const location = readLocation(row.locationEncrypted);
if (!location) continue;
const key = `${location.lat},${location.lon}`;
const group = byLocation.get(key) ?? {
@@ -394,17 +394,13 @@ describe("the environment home (v1.42, sealed at rest)", () => {
);
});
it("seals a readable home from a file and writes the readable columns empty", () => {
it("seals a readable home from a file", () => {
const { data, refused } = admitAccountSettings(
{ homeLat: 48.137, homeLon: 11.575, homeLabel: "Munich" },
ctx,
);
expect(refused).toEqual([]);
expect(data).toMatchObject({
homeLat: null,
homeLon: null,
homeLabel: null,
});
expect(data).not.toHaveProperty("homeLat");
expect(openLocation(data.homeLocationEncrypted as Uint8Array)).toEqual({
lat: 48.1,
lon: 11.6,
@@ -421,11 +417,6 @@ describe("the environment home (v1.42, sealed at rest)", () => {
expect(Buffer.from(restored).equals(Buffer.from(sealed))).toBe(true);
expect(
admitAccountSettings({ homeLat: null, homeLon: null }, ctx).data,
).toEqual({
homeLocationEncrypted: null,
homeLat: null,
homeLon: null,
homeLabel: null,
});
).toEqual({ homeLocationEncrypted: null });
});
});
+14 -36
View File
@@ -41,6 +41,7 @@ import { Buffer } from "node:buffer";
import { Prisma, type PrismaClient } from "@/generated/prisma/client";
import {
AiUsageLevel,
DateFormatPreference,
InjectionSite,
TimeFormatPreference,
@@ -384,21 +385,19 @@ const insuranceNumber: SettingCodec = {
/**
* v1.42 (#615) — the environment home, sealed at rest as one value
* (`homeLocationEncrypted`, see `src/lib/environment/location-cipher.ts`)
* beside the readable `homeLat` / `homeLon` / `homeLabel` that the
* encryption backfill empties. The four columns travel as one, the way the
* avatar's three do:
* (`homeLocationEncrypted`, see `src/lib/environment/location-cipher.ts`).
* The readable `homeLat` / `homeLon` / `homeLabel` columns were dropped in
* v1.43; the three keys live on in the file:
*
* - a portable file carries the home readable, opened from the sealed copy
* when there is one, and no sealed value;
* - a disaster-recovery file carries the sealed value verbatim (base64)
* beside whatever the readable columns still hold.
* - a portable file carries the home readable (`homeLat` / `homeLon` /
* `homeLabel`), opened from the sealed copy, and no sealed value;
* - a disaster-recovery file carries the sealed value verbatim (base64).
*
* The restore writes the sealed value as it came, or seals a readable home
* under this host's key, and writes the readable columns empty either way. A
* file whose home is null clears the account's home. A home without a label
* (allowed since v1.25) is sealed with an empty label, which every reader
* shows as no label.
* (a portable file, or a disaster-recovery file written before v1.43) under
* this host's key. A file whose home is null clears the account's home. A
* home without a label (allowed since v1.25) is sealed with an empty label,
* which every reader shows as no label.
*/
const homeLocation: SettingCodec = {
write: (row, disasterRecovery) => {
@@ -408,21 +407,13 @@ const homeLocation: SettingCodec = {
: null;
if (disasterRecovery) {
return {
homeLat: row.homeLat,
homeLon: row.homeLon,
homeLabel: row.homeLabel,
homeLocationEncrypted:
sealed && sealed.byteLength > 0
? Buffer.from(sealed).toString("base64")
: null,
};
}
const home = readLocation({
sealed,
lat: row.homeLat as number | null,
lon: row.homeLon as number | null,
label: (row.homeLabel as string | null) ?? "",
});
const home = readLocation(sealed);
return home
? {
homeLat: home.lat,
@@ -432,12 +423,7 @@ const homeLocation: SettingCodec = {
: { homeLat: null, homeLon: null, homeLabel: null };
},
read: (entry) => {
const cleared = {
homeLocationEncrypted: null,
homeLat: null,
homeLon: null,
homeLabel: null,
};
const cleared = { homeLocationEncrypted: null };
const sealedValue = entry.homeLocationEncrypted;
if (has(entry, "homeLocationEncrypted") && sealedValue !== null) {
if (typeof sealedValue !== "string") {
@@ -471,12 +457,6 @@ const homeLocation: SettingCodec = {
},
};
/** Carried by the home codec above. */
const ridesWithHome: SettingCodec = {
write: () => ({}),
read: () => undefined,
};
const ACCOUNT_SETTING_CODECS: Readonly<
Record<AccountSettingColumn, SettingCodec>
> = {
@@ -493,9 +473,6 @@ const ACCOUNT_SETTING_CODECS: Readonly<
avatarBytes: avatar,
avatarContentType: ridesWithAvatar,
avatarUpdatedAt: ridesWithAvatar,
homeLat: ridesWithHome,
homeLon: ridesWithHome,
homeLabel: ridesWithHome,
homeTimezone: timeZone("homeTimezone", true),
homeSince: instant("homeSince"),
homeLocationEncrypted: homeLocation,
@@ -545,6 +522,7 @@ const ACCOUNT_SETTING_CODECS: Readonly<
),
insightsExcludeMetrics: plain("insightsExcludeMetrics"),
disableCoach: plain("disableCoach"),
aiUsageLevel: oneOf("aiUsageLevel", Object.values(AiUsageLevel), true),
coachPrefsJson: json("coachPrefsJson"),
documentsAutoAiRead: plain("documentsAutoAiRead"),
labsLocalOcrEnabled: plain("labsLocalOcrEnabled"),
+48 -5
View File
@@ -205,6 +205,21 @@ export const BACKED_UP_MODELS = [
// restore without the row opens a blank questionnaire for somebody who has
// already answered it.
"OnboardingRecord",
// ── v1.43 ─────────────────────────────────────────────────────────────────
// A marker's other spellings and its merge ledger, a supplement's
// composition, the Dawarich daily values, and the five relation links that
// close the relation matrix. Each is owned data nothing recomputes; each is
// on COVERAGE_PENDING until its section carries it.
"BiomarkerAlias",
"BiomarkerMerge",
"MedicationIngredient",
"DawarichDay",
"LabResultDocumentLink",
"EncounterMedicationLink",
"EncounterVaccinationLink",
"MedicationDocumentLink",
"MedicationConditionLink",
] as const;
/**
@@ -597,6 +612,26 @@ export const STRUCTURALLY_UNATTRIBUTABLE: Readonly<Record<string, string>> = {
* rather than by what is cheapest to write.
*/
export const COVERAGE_PENDING: Readonly<Record<string, string>> = {
// v1.43 — the new tables are declared here with the schema and move to
// TWO_ENDED_MODELS when their section writer and restore branch land.
BiomarkerAlias:
"Every other spelling a marker answers to: the names folded in by a merge, the choices made at a scan or a document import, the ones typed on the marker. Without them a restored account asks again at the next scan which marker each spelling belongs to.",
BiomarkerMerge:
"The ledger of which markers were merged into which. Without it a link or a cached screen that still names a merged marker reads as a plain 404 instead of being sent to the marker it went into.",
MedicationIngredient:
"What one tablet or capsule of a supplement contains. Without it a restored account's supplements stop counting toward the day's micronutrients, and the composition has to be typed in again from the label.",
DawarichDay:
"The daily movement values fetched from Dawarich: distances, places visited, time away from home, travel days. Days the Dawarich instance has since pruned cannot be fetched again, so they are lost for good.",
LabResultDocumentLink:
"Which stored report a lab result was read from. Without it the result and the report stop pointing at each other, and the result loses its source.",
EncounterMedicationLink:
"Which medications a visit prescribed, changed or reviewed. Without it the visit and the medication stop pointing at each other.",
EncounterVaccinationLink:
"Which doses were given at a visit. Without it the visit and the dose stop pointing at each other.",
MedicationDocumentLink:
"Which documents belong to a medication: the prescription, the plan, the package insert. Without it they stop pointing at each other.",
MedicationConditionLink:
"Which condition a medication treats (its indication). Without it the medication and the condition stop pointing at each other.",
WorkoutRoute:
"GPS traces. Deliberately absent from the payload today and DISCLOSED as absent in the file's own manifest, which is why this is a documented exclusion rather than a silent one — but it is still a loss for a self-hoster with no other copy.",
WorkoutSamples:
@@ -879,6 +914,13 @@ export const USER_COLUMN_BACKUP_CLASS = {
nightscoutUrlEncrypted: "CREDENTIAL",
nightscoutTokenEncrypted: "CREDENTIAL",
nightscoutAllowPrivateHost: "CREDENTIAL",
// v1.43 — the Dawarich connection, credentials like the Nightscout pair. The
// sealed preferences name a place id on that instance, which means nothing
// without the key, so they stay behind with it.
dawarichUrlEncrypted: "CREDENTIAL",
dawarichApiKeyEncrypted: "CREDENTIAL",
dawarichPrefsEncrypted: "CREDENTIAL",
dawarichAllowPrivateHost: "CREDENTIAL",
polarAccessTokenEncrypted: "CREDENTIAL",
polarUserIdEncrypted: "CREDENTIAL",
polarClientIdEncrypted: "CREDENTIAL",
@@ -935,13 +977,10 @@ export const USER_COLUMN_BACKUP_CLASS = {
avatarContentType: "SETTING",
avatarUpdatedAt: "SETTING",
// Home, which the environment history is resolved against.
homeLat: "SETTING",
homeLon: "SETTING",
homeLabel: "SETTING",
homeTimezone: "SETTING",
homeSince: "SETTING",
// v1.42 (#615) — the sealed copy of the same home location, and the
// account's switch for the air-quality part of the module.
// v1.42 (#615) — the sealed home location (the only copy since v1.43),
// and the account's switch for the air-quality part of the module.
homeLocationEncrypted: "SETTING",
environmentAirQualityEnabled: "SETTING",
// How the interface reads.
@@ -983,6 +1022,10 @@ export const USER_COLUMN_BACKUP_CLASS = {
insightsPrivacyMode: "SETTING",
insightsExcludeMetrics: "SETTING",
disableCoach: "SETTING",
// v1.43 (#1217) — the account's own AI usage level; NULL follows the
// instance default, and a restored level above this host's ceiling is
// capped at read time like any stored choice.
aiUsageLevel: "SETTING",
coachPrefsJson: "SETTING",
documentsAutoAiRead: "SETTING",
labsLocalOcrEnabled: "SETTING",
+21 -49
View File
@@ -14,8 +14,8 @@
* the environmental readings mean anything", and the second line is the one
* that decides the design.
*
* A reading carries the coarse location it was fetched for (`lat`, `lon`,
* `locationLabel`) and the precedence rule that chose it (`source`). A
* A reading carries the coarse location it was fetched for (sealed; `lat`,
* `lon`, `locationLabel` in a portable file) and the precedence rule that chose it (`source`). A
* `TRAVEL` reading exists because an explicit dated location period covered
* that day. `resolveLocationForDay` in `src/lib/environment/service.ts` reads
* those periods live, every time; nothing else can produce a `TRAVEL`
@@ -60,14 +60,13 @@
* - a portable export opens the sealed location and writes the readable
* `lat` / `lon` / label, and no `locationEncrypted` at all, so the file
* reads anywhere;
* - a disaster-recovery file carries `locationEncrypted` verbatim (base64)
* beside whatever readable columns the row still holds, for a host with
* the same keys.
* - a disaster-recovery file carries `locationEncrypted` verbatim (base64),
* for a host with the same keys; its readable fields are null.
*
* The restore takes either: a sealed value is written as it came, a readable
* one is sealed under this host's key, and the readable columns are written
* empty in both cases. A row that holds neither restores without a location;
* it is never invented.
* one (a portable file, or a disaster-recovery file written before v1.43) is
* sealed under this host's key. A row that holds neither restores without a
* location; it is never invented.
*
* The air-quality columns (v1.42) are plain values and ride both purposes
* alike. Every one is optional on the way in: a file written before them
@@ -129,8 +128,8 @@ export interface EnvironmentContextBackupEntry extends AirQualityBackupColumns {
/** `YYYY-MM-DD`, anchored to the resolved location's timezone. */
date: string;
/**
* Plaintext coarse location. Nullable since v1.42 (migration 0382): the
* sealed copy replaces it once the encryption backfill has run.
* Readable coarse location: set in a portable file, null in a
* disaster-recovery file (which carries `locationEncrypted`).
*/
lat: number | null;
lon: number | null;
@@ -169,7 +168,7 @@ export interface EnvironmentTravelLocationBackupEntry {
/** Inclusive `YYYY-MM-DD` bounds. Strings end to end; see the file header. */
startDate: string;
endDate: string;
/** Plaintext coarse location; nullable since v1.42 (migration 0382). */
/** Readable coarse location; set in a portable file, null otherwise. */
lat: number | null;
lon: number | null;
label: string | null;
@@ -198,9 +197,6 @@ export interface EnvironmentBackupCounts {
*/
const ENVIRONMENT_CONTEXT_BACKUP_SELECT = {
date: true,
lat: true,
lon: true,
locationLabel: true,
locationEncrypted: true,
source: true,
tempMin: true,
@@ -245,9 +241,6 @@ const ENVIRONMENT_CONTEXT_BACKUP_SELECT = {
const ENVIRONMENT_TRAVEL_LOCATION_BACKUP_SELECT = {
startDate: true,
endDate: true,
lat: true,
lon: true,
label: true,
locationEncrypted: true,
createdAt: true,
updatedAt: true,
@@ -261,16 +254,12 @@ function toBase64(bytes: Uint8Array | null): string | null {
}
/**
* The location columns of one entry: opened and readable for a portable
* file, as stored plus the sealed value for a disaster-recovery file.
* The location fields of one entry: opened and readable for a portable file;
* the sealed value for a disaster-recovery file, whose readable fields stay
* null (the database holds no readable copy since v1.43).
*/
function locationFields(
row: {
lat: number | null;
lon: number | null;
label: string | null;
locationEncrypted: Uint8Array | null;
},
sealed: Uint8Array | null,
disasterRecovery: boolean,
): {
lat: number | null;
@@ -280,19 +269,13 @@ function locationFields(
} {
if (disasterRecovery) {
return {
lat: row.lat,
lon: row.lon,
label: row.label,
locationEncrypted: toBase64(row.locationEncrypted),
lat: null,
lon: null,
label: null,
locationEncrypted: toBase64(sealed),
};
}
const location = readLocation({
sealed: row.locationEncrypted,
lat: row.lat,
lon: row.lon,
label: row.label,
});
return location ?? { lat: null, lon: null, label: null };
return readLocation(sealed) ?? { lat: null, lon: null, label: null };
}
/**
@@ -326,12 +309,7 @@ export async function buildEnvironmentBackupSection(
return {
environmentContexts: contextRows.map((row) => {
const { label, ...location } = locationFields(
{
lat: row.lat,
lon: row.lon,
label: row.locationLabel,
locationEncrypted: row.locationEncrypted,
},
row.locationEncrypted,
disasterRecovery,
);
return {
@@ -381,7 +359,7 @@ export async function buildEnvironmentBackupSection(
environmentTravelLocations: travelRows.map((row) => ({
startDate: row.startDate,
endDate: row.endDate,
...locationFields(row, disasterRecovery),
...locationFields(row.locationEncrypted, disasterRecovery),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
})),
@@ -543,9 +521,6 @@ export async function restoreEnvironmentData(
`environmentTravelLocations.${entry.startDate}..${entry.endDate}`,
unopened,
),
lat: null,
lon: null,
label: null,
...(entry.createdAt ? { createdAt: new Date(entry.createdAt) } : {}),
...(entry.updatedAt ? { updatedAt: new Date(entry.updatedAt) } : {}),
})),
@@ -567,9 +542,6 @@ export async function restoreEnvironmentData(
`environmentContexts.${entry.date}`,
unopened,
),
lat: null,
lon: null,
locationLabel: null,
source: entry.source,
tempMin: entry.tempMin ?? null,
tempMax: entry.tempMax ?? null,
+25 -1
View File
@@ -23,7 +23,7 @@
*/
import { Buffer } from "node:buffer";
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import { Prisma, type PrismaClient } from "@/generated/prisma/client";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { encryptNote } from "@/lib/crypto/note-cipher";
import { getEvent } from "@/lib/logging/context";
@@ -47,6 +47,19 @@ export interface ProfileBackupOptions {
* The account's durable self-context. One row per user, so there is no
* reference to resolve on the way back in — the owner is the key.
*/
/**
* The stored section confirmations as the file carries them: an object of
* strings, anything else read as none.
*/
function lifeConfirmed(value: unknown): Record<string, string> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const out: Record<string, string> = {};
for (const [key, stamp] of Object.entries(value)) {
if (typeof stamp === "string") out[key] = stamp;
}
return out;
}
export interface HealthProfileBackupEntry {
/** Present in canonical DR payloads so the row keeps a stable identity. */
id?: string;
@@ -91,6 +104,12 @@ export interface HealthProfileBackupEntry {
* state it was in, pending prompts included.
*/
pendingQuestionsEncrypted?: string | null;
/**
* v1.43 (#614) — when each life-circumstance section was last confirmed as
* still true: section key to ISO instant. Plaintext keys and stamps only,
* carried by value in both purposes. Absent in files written before v1.43.
*/
lifeConfirmedJson?: Record<string, string> | null;
createdAt?: string;
updatedAt?: string;
}
@@ -329,6 +348,7 @@ export async function buildProfileBackupSection(
HealthProfileAiSection[] | undefined) ?? [
...DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
],
lifeConfirmedJson: lifeConfirmed(profileRow.lifeConfirmedJson),
emergencyBloodType: profileRow.emergencyBloodType ?? null,
organDonorStatus: profileRow.organDonorStatus ?? null,
advanceDirectiveStatus: profileRow.advanceDirectiveStatus ?? null,
@@ -377,6 +397,7 @@ export async function buildProfileBackupSection(
HealthProfileAiSection[] | undefined) ?? [
...DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
],
lifeConfirmedJson: lifeConfirmed(profileRow.lifeConfirmedJson),
emergencyBloodType: profileRow.emergencyBloodType ?? null,
organDonorStatus: profileRow.organDonorStatus ?? null,
advanceDirectiveStatus: profileRow.advanceDirectiveStatus ?? null,
@@ -788,6 +809,9 @@ export async function restoreProfileData(
// resolution as the self-context columns above.
emergencyBloodType: p.emergencyBloodType,
organDonorStatus: p.organDonorStatus,
// A file written before v1.43 says nothing about confirmations; the
// account then has none, which only re-offers the yearly check.
lifeConfirmedJson: p.lifeConfirmedJson ?? Prisma.DbNull,
advanceDirectiveStatus: p.advanceDirectiveStatus,
emergencyContactsEncrypted: resolveProfileColumn(
p.emergencyContactsEncrypted,
@@ -5,7 +5,6 @@ import {
decryptRouteGeometry,
encryptRouteGeometry,
} from "@/lib/workouts/route-geometry-cipher";
import { openLocation, sealLocation } from "@/lib/environment/location-cipher";
// ── in-memory store backing a minimal prisma mock ──────────────────────────
interface ConversationRow {
@@ -38,36 +37,11 @@ interface RouteRow {
geometryEncrypted: Uint8Array | null;
}
/** v1.42 — an account's environment home; `userId` is its own id. */
interface HomeRow {
id: string;
userId: string;
homeLat: number | null;
homeLon: number | null;
homeLabel: string | null;
homeLocationEncrypted: Uint8Array | null;
updatedAt?: Date;
}
/** v1.42 — a dated location period or a stored day (label column named). */
interface PlaceRow {
id: string;
userId: string;
lat: number | null;
lon: number | null;
label?: string | null;
locationLabel?: string | null;
locationEncrypted: Uint8Array | null;
updatedAt?: Date;
}
const store = vi.hoisted(() => ({
conversations: [] as ConversationRow[],
entries: [] as EntryRow[],
practitioners: [] as PractitionerRow[],
routes: [] as RouteRow[],
homes: [] as HomeRow[],
travels: [] as PlaceRow[],
days: [] as PlaceRow[],
}));
vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: () => null }));
@@ -144,9 +118,6 @@ vi.mock("@/lib/db", () => {
customMetricEntry: delegate(() => store.entries, ["note"]),
practitioner: delegate(() => store.practitioners, ["phone", "location"]),
workoutRoute: delegate(() => store.routes, ["geometry"]),
user: delegate(() => store.homes, ["homeLat"]),
environmentTravelLocation: delegate(() => store.travels, ["lat"]),
environmentContext: delegate(() => store.days, ["lat"]),
};
return {
prisma: {
@@ -254,72 +225,6 @@ beforeEach(() => {
},
{ id: "r3", userId: "u2", geometry: TRACK, geometryEncrypted: null },
];
const stamp = new Date("2026-02-01T08:00:00.000Z");
store.homes = [
{
id: "u1",
userId: "u1",
homeLat: 51.5,
homeLon: 7.2,
homeLabel: "Bochum, Germany",
homeLocationEncrypted: null,
updatedAt: stamp,
},
{
id: "u2",
userId: "u2",
homeLat: 48.1,
homeLon: 11.6,
homeLabel: "Munich, Germany",
homeLocationEncrypted: null,
updatedAt: stamp,
},
];
store.travels = [
{
id: "t1",
userId: "u1",
lat: 38.7,
lon: -9.1,
label: "Lisbon, Portugal",
locationEncrypted: null,
updatedAt: stamp,
},
// Already sealed by this release's writer.
{
id: "t2",
userId: "u1",
lat: null,
lon: null,
label: null,
locationEncrypted: sealLocation({
lat: 41.4,
lon: 2.2,
label: "Barcelona, Spain",
}),
updatedAt: stamp,
},
];
store.days = [
{
id: "d1",
userId: "u1",
lat: 38.7,
lon: -9.1,
locationLabel: "Lisbon, Portugal",
locationEncrypted: null,
updatedAt: stamp,
},
{
id: "d2",
userId: "u2",
lat: 48.1,
lon: 11.6,
locationLabel: "Munich, Germany",
locationEncrypted: null,
updatedAt: stamp,
},
];
});
afterEach(() => {
@@ -337,9 +242,6 @@ describe("runFreeTextEncryptionBackfillForUser", () => {
routeGeometriesMigrated: 1,
appointmentAddressesCleared: 0,
contactAuditRowsScrubbed: 0,
homeLocationsMigrated: 1,
travelLocationsMigrated: 1,
dayLocationsMigrated: 1,
});
const c1 = store.conversations.find((r) => r.id === "c1")!;
@@ -443,64 +345,12 @@ describe("runFreeTextEncryptionBackfillForUser", () => {
routeGeometriesMigrated: 0,
appointmentAddressesCleared: 0,
contactAuditRowsScrubbed: 0,
homeLocationsMigrated: 0,
travelLocationsMigrated: 0,
dayLocationsMigrated: 0,
});
expect(store.conversations.find((r) => r.id === "c1")!.titleEncrypted).toBe(
sealed,
);
});
it("seals the environment home, periods and days, clears the readable columns, keeps the stamps", async () => {
const t2Before = store.travels.find(
(r) => r.id === "t2",
)!.locationEncrypted;
await runFreeTextEncryptionBackfillForUser("u1");
const home = store.homes.find((r) => r.id === "u1")!;
expect(home).toMatchObject({
homeLat: null,
homeLon: null,
homeLabel: null,
});
expect(openLocation(home.homeLocationEncrypted!)).toEqual({
lat: 51.5,
lon: 7.2,
label: "Bochum, Germany",
});
expect(home.updatedAt).toEqual(new Date("2026-02-01T08:00:00.000Z"));
const t1 = store.travels.find((r) => r.id === "t1")!;
expect(t1).toMatchObject({ lat: null, lon: null, label: null });
expect(openLocation(t1.locationEncrypted!)).toEqual({
lat: 38.7,
lon: -9.1,
label: "Lisbon, Portugal",
});
expect(store.travels.find((r) => r.id === "t2")!.locationEncrypted).toBe(
t2Before,
);
const d1 = store.days.find((r) => r.id === "d1")!;
expect(d1).toMatchObject({ lat: null, lon: null, locationLabel: null });
expect(openLocation(d1.locationEncrypted!).label).toBe("Lisbon, Portugal");
// Another account's rows are untouched.
expect(store.homes.find((r) => r.id === "u2")!.homeLat).toBe(48.1);
expect(store.days.find((r) => r.id === "d2")!.lat).toBe(48.1);
});
it("seals a home stored without a label with an empty one", async () => {
store.homes[0].homeLabel = null;
await runFreeTextEncryptionBackfillForUser("u1");
expect(openLocation(store.homes[0].homeLocationEncrypted!)).toEqual({
lat: 51.5,
lon: 7.2,
label: "",
});
});
it("is fail-closed: without a key every readable row stays intact", async () => {
vi.stubEnv("ENCRYPTION_KEY", "");
vi.stubEnv("ENCRYPTION_KEYS", "");
@@ -509,10 +359,6 @@ describe("runFreeTextEncryptionBackfillForUser", () => {
title: "Why is my pressure up after the new tablets?",
titleEncrypted: null,
});
expect(store.days.find((r) => r.id === "d1")).toMatchObject({
lat: 38.7,
locationEncrypted: null,
});
});
});
@@ -132,7 +132,6 @@ export async function discoverAirQualityHistory(
environmentAirQualityEnabled: true,
OR: [
{ homeLocationEncrypted: { not: null } },
{ homeLat: { not: null }, homeLon: { not: null } },
{ environmentTravelLocations: { some: {} } },
],
},
+1 -8
View File
@@ -146,14 +146,7 @@ export async function enqueueEnvironmentFetchDiscovery(
// consults the per-user preference map + the operator availability.
const [candidates, operatorAvailability] = await Promise.all([
prisma.user.findMany({
// The sealed home (v1.42), or the readable one a row the encryption
// backfill has not reached yet still carries.
where: {
OR: [
{ homeLocationEncrypted: { not: null } },
{ homeLat: { not: null }, homeLon: { not: null } },
],
},
where: { homeLocationEncrypted: { not: null } },
select: { id: true, modulePreferencesJson: true },
}),
getOperatorModuleAvailability(),
+9 -170
View File
@@ -22,15 +22,10 @@
* column holds a JSON null rather than SQL NULL has nothing to seal; it is set
* to SQL NULL so discovery stops finding it.
*
* v1.42 (#615) adds the environment module's coarse locations: the home on
* the user row (`homeLat` / `homeLon` / `homeLabel` -> `homeLocationEncrypted`),
* each dated location period and each stored day (`lat` / `lon` / label ->
* `locationEncrypted`), each sealed as one `{ lat, lon, label }` value under
* the environment-location label (`location-cipher.ts`). A row whose readable
* triple is incomplete (a home without a label is sealed with an empty one;
* a day or period cannot be incomplete, those
* columns were NOT NULL until migration 0382) has its readable columns
* cleared all the same, so it leaves discovery.
* v1.42 (#615) added the environment module's coarse locations (home, dated
* location periods, stored days). v1.43 dropped their readable columns
* (migration 0409, which refuses to run while one still holds a value), so
* that part of the pass is gone.
*
* Modelled on `med-notes-encryption-backfill.ts`: a discovery query enqueues
* one job per user still holding an un-migrated row, the per-user handler walks
@@ -54,9 +49,9 @@
* after the upgrade (a rename, an edited note). Its readable value is then
* the newer one, and it is the one sealed.
*
* The readable columns are NOT dropped in this release. That is a follow-up
* release, once this backfill reports zero remaining rows on every instance;
* every one of them carries the marker in `schema.prisma`.
* The remaining readable columns are dropped in a follow-up release, once
* this backfill reports zero remaining rows on every instance; every one of
* them carries the marker in `schema.prisma`.
*
* Boot discovery is staggered past the startup storm (`startAfter`), like the
* other boot backfills; the daily discovery tick passes no offset.
@@ -72,7 +67,6 @@ import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { encryptNote } from "@/lib/crypto/note-cipher";
import { encryptRouteGeometry } from "@/lib/workouts/route-geometry-cipher";
import { sealLocation } from "@/lib/environment/location-cipher";
export const FREE_TEXT_ENCRYPTION_BACKFILL_QUEUE =
"free-text-encryption-backfill";
@@ -100,12 +94,6 @@ export interface FreeTextEncryptionBackfillSummary {
appointmentAddressesCleared: number;
/** v1.39.4 — edit audit rows whose old phone number / address was removed. */
contactAuditRowsScrubbed: number;
/** v1.42 — the account's environment home sealed (0 or 1). */
homeLocationsMigrated: number;
/** v1.42 — dated environment location periods sealed. */
travelLocationsMigrated: number;
/** v1.42 — stored environment days whose location was sealed. */
dayLocationsMigrated: number;
}
/** The audit action whose older rows carried contact values. */
@@ -303,107 +291,6 @@ async function migrateRouteGeometry(id: string): Promise<boolean> {
});
}
/**
* Seal one account's readable environment home into `homeLocationEncrypted`
* and clear the readable columns, atomically and idempotently. The user row
* keeps its own `updatedAt`: moving a value into ciphertext is not an edit.
*/
async function migrateHomeLocation(id: string): Promise<boolean> {
return prisma.$transaction(async (tx) => {
const fresh = await tx.user.findUnique({
where: { id },
select: {
homeLat: true,
homeLon: true,
homeLabel: true,
updatedAt: true,
},
});
if (!fresh || fresh.homeLat === null) return false;
const { count } = await tx.user.updateMany({
where: { id, updatedAt: fresh.updatedAt },
data: {
updatedAt: fresh.updatedAt,
...(fresh.homeLon !== null
? {
homeLocationEncrypted: sealLocation({
lat: fresh.homeLat,
lon: fresh.homeLon,
// A home stored without a label seals an empty one, which
// every reader shows as no label.
label: fresh.homeLabel ?? "",
}),
}
: {}),
homeLat: null,
homeLon: null,
homeLabel: null,
},
});
return count === 1;
});
}
/** Seal one dated location period's readable location, as above. */
async function migrateTravelLocation(id: string): Promise<boolean> {
return prisma.$transaction(async (tx) => {
const fresh = await tx.environmentTravelLocation.findUnique({
where: { id },
select: { lat: true, lon: true, label: true, updatedAt: true },
});
if (!fresh || fresh.lat === null) return false;
const { count } = await tx.environmentTravelLocation.updateMany({
where: { id, updatedAt: fresh.updatedAt },
data: {
updatedAt: fresh.updatedAt,
...(fresh.lon !== null && fresh.label !== null
? {
locationEncrypted: sealLocation({
lat: fresh.lat,
lon: fresh.lon,
label: fresh.label,
}),
}
: {}),
lat: null,
lon: null,
label: null,
},
});
return count === 1;
});
}
/** Seal one stored day's readable location, as above. */
async function migrateDayLocation(id: string): Promise<boolean> {
return prisma.$transaction(async (tx) => {
const fresh = await tx.environmentContext.findUnique({
where: { id },
select: { lat: true, lon: true, locationLabel: true, updatedAt: true },
});
if (!fresh || fresh.lat === null) return false;
const { count } = await tx.environmentContext.updateMany({
where: { id, updatedAt: fresh.updatedAt },
data: {
updatedAt: fresh.updatedAt,
...(fresh.lon !== null && fresh.locationLabel !== null
? {
locationEncrypted: sealLocation({
lat: fresh.lat,
lon: fresh.lon,
label: fresh.locationLabel,
}),
}
: {}),
lat: null,
lon: null,
locationLabel: null,
},
});
return count === 1;
});
}
/**
* Walk one kind of row page by page, migrating each. `page` returns the ids
* still holding a readable value; each migration clears it, so a migrated row
@@ -511,39 +398,6 @@ export async function runFreeTextEncryptionBackfillForUser(
scrubContactAuditRow,
once,
);
// v1.42 — the environment locations. The 0382 partial indexes match these
// predicates.
const homeLocationsMigrated = await drain(
() =>
prisma.user.findMany({
where: { id: userId, homeLat: { not: null } },
select: { id: true },
take: PAGE_SIZE,
}),
migrateHomeLocation,
once,
);
const travelLocationsMigrated = await drain(
() =>
prisma.environmentTravelLocation.findMany({
where: { userId, lat: { not: null } },
select: { id: true },
take: PAGE_SIZE,
}),
migrateTravelLocation,
once,
);
const dayLocationsMigrated = await drain(
() =>
prisma.environmentContext.findMany({
where: { userId, lat: { not: null } },
select: { id: true },
take: PAGE_SIZE,
}),
migrateDayLocation,
once,
);
annotate({
action: {
name: "free_text.encryption.backfill",
@@ -554,9 +408,6 @@ export async function runFreeTextEncryptionBackfillForUser(
route_geometries_migrated: routeGeometriesMigrated,
appointment_addresses_cleared: appointmentAddressesCleared,
contact_audit_rows_scrubbed: contactAuditRowsScrubbed,
home_locations_migrated: homeLocationsMigrated,
travel_locations_migrated: travelLocationsMigrated,
day_locations_migrated: dayLocationsMigrated,
},
},
});
@@ -568,16 +419,13 @@ export async function runFreeTextEncryptionBackfillForUser(
routeGeometriesMigrated,
appointmentAddressesCleared,
contactAuditRowsScrubbed,
homeLocationsMigrated,
travelLocationsMigrated,
dayLocationsMigrated,
};
}
/**
* Discovery. Finds every user still holding a readable value in any of the
* columns above and enqueues one backfill job per account. The 0357, 0361 and
* 0382 partial indexes match the predicates, so the scan is index-only and converges to empty.
* columns above and enqueues one backfill job per account. The 0357 and 0361
* partial indexes match the predicates, so the scan is index-only and converges to empty.
* The appointment-address and audit predicates have no partial index: the
* first scans the reminder table, the second reads one action through the
* `(action, created_at)` index; both converge to empty the same way. pg-boss
@@ -625,15 +473,6 @@ export async function enqueueBootTimeFreeTextEncryptionBackfill(
WHERE action = ${CONTACT_AUDIT_ACTION}
AND user_id IS NOT NULL
AND (details LIKE '%"location":%' OR details LIKE '%"phone":%')`,
prisma.$queryRaw<{ user_id: string }[]>`
SELECT id AS user_id FROM users
WHERE home_lat IS NOT NULL`,
prisma.$queryRaw<{ user_id: string }[]>`
SELECT DISTINCT user_id FROM environment_travel_locations
WHERE lat IS NOT NULL`,
prisma.$queryRaw<{ user_id: string }[]>`
SELECT DISTINCT user_id FROM environment_contexts
WHERE lat IS NOT NULL`,
]);
const userIds = new Set<string>();
+9
View File
@@ -811,6 +811,13 @@ const healthProfileBackupSchema = z
aiIncludedSections: z
.array(healthProfileAiSectionSchema)
.default([...DEFAULT_HEALTH_PROFILE_AI_SECTIONS]),
// v1.43 (#614) — section confirmations, section key to ISO instant.
// Carried as stored; the read route judges each stamp, so an unreadable
// one costs a "still true?" prompt rather than the whole restore.
lifeConfirmedJson: z
.record(z.string().max(64), z.string().max(64))
.nullable()
.optional(),
// Emergency profile: three plaintext enums (carried by value in both
// purposes), three free-text columns following the ciphertext-or-plaintext
// split of the self-context fields above.
@@ -2031,6 +2038,8 @@ const accountSettingsBackupSchema = z
insightsPrivacyMode: z.string().optional(),
insightsExcludeMetrics: z.array(z.string()).optional(),
disableCoach: z.boolean().optional(),
// v1.43 (#1217) — the account's AI usage level; the codec checks the value.
aiUsageLevel: z.string().nullable().optional(),
coachPrefsJson: z.unknown().optional(),
documentsAutoAiRead: z.boolean().optional(),
labsLocalOcrEnabled: z.boolean().optional(),
+15
View File
@@ -16,6 +16,21 @@ import { isValidTimezone } from "@/lib/tz/format";
export const assessmentInstrumentEnum = z.enum(["PHQ9", "GAD7", "WHO5", "SCI"]);
/**
* The mental-health screeners, as opposed to every `AssessmentInstrument`.
* Since v1.43 the table also stores AUDIT-C (the alcohol section of the life
* circumstances), which no mental-health surface may list or score: older app
* builds decode `instrument` against exactly these four values.
*/
export const SCREENER_INSTRUMENTS = assessmentInstrumentEnum.options;
export type ScreenerInstrument = (typeof SCREENER_INSTRUMENTS)[number];
export function isScreenerInstrument(
instrument: string,
): instrument is ScreenerInstrument {
return (SCREENER_INSTRUMENTS as readonly string[]).includes(instrument);
}
/**
* Client provenance for an administration. Closed enum so the column can never
* carry a free-form label. `WEB` is the in-app questionnaire (the DB default);