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

+12 -37
View File
@@ -1426,7 +1426,7 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
// coordinates are at 1 decimal, the precision the app stores since v1.39.4;
// a restore rounds anything finer, so finer values would not round-trip.
// Since v1.42 the location is stored sealed (`locationEncrypted`), the way
// the writers store it; the readable columns stay empty.
// the writers store it; it is the only copy since v1.43.
await prisma.environmentTravelLocation.create({
data: {
userId: OWNER_ID,
@@ -3602,22 +3602,6 @@ const COLUMN_EXCLUSIONS: Readonly<Record<string, string>> = {
"insert stamp of a symptom link; no reader, the day log's own timestamps travel",
"IllnessSymptomLink.createdAt":
"insert stamp of a symptom link; no reader, the illness day log's own timestamps travel",
// v1.42 (#615) — the readable copies of the sealed environment locations.
// The restore seals whatever location a file carries into
// `locationEncrypted` and writes these empty; the location itself is
// compared through the sealed column. They drop in v1.43.
"EnvironmentContext.lat":
"readable copy of the sealed day location; the restore seals it and writes this empty",
"EnvironmentContext.lon":
"readable copy of the sealed day location; the restore seals it and writes this empty",
"EnvironmentContext.locationLabel":
"readable copy of the sealed day location; the restore seals it and writes this empty",
"EnvironmentTravelLocation.lat":
"readable copy of the sealed period location; the restore seals it and writes this empty",
"EnvironmentTravelLocation.lon":
"readable copy of the sealed period location; the restore seals it and writes this empty",
"EnvironmentTravelLocation.label":
"readable copy of the sealed period location; the restore seals it and writes this empty",
};
/**
@@ -3638,7 +3622,14 @@ const COLUMN_PRESENCE_ONLY: Readonly<Record<string, string>> = {
* starts coming back, so the entry has to go in the change that carries it.
* The list is empty again before the release is tagged.
*/
const RELEASE_PENDING_COLUMNS: Readonly<Record<string, string>> = {};
const RELEASE_PENDING_COLUMNS: Readonly<Record<string, string>> = {
"IntradayCumulativeProfile.hourlyIncrements":
"hour buckets for summed types (ios #77): the intraday section carries it with the hour-bucket ingest",
"IntradayCumulativeProfile.basis":
"hour buckets for summed types (ios #77): the intraday section carries it with the hour-bucket ingest",
"IntradayCumulativeProfile.bucketCount":
"hour buckets for summed types (ios #77): the intraday section carries it with the hour-bucket ingest",
};
/**
* Columns every reader parses through one function before using, compared
@@ -3665,6 +3656,8 @@ const FILL_OVERRIDES: Readonly<Record<string, unknown>> = {
"MoodEntry.tags": JSON.stringify(["round-trip-tag"]),
"CorrelationPattern.qValue": 0.04,
"CorrelationPattern.dismissedEvidenceHash": "ab".repeat(32),
// CHECK in migration 0408: the profile is built from samples or from hour buckets.
"IntradayCumulativeProfile.basis": "HOUR_BUCKETS",
};
/**
@@ -4155,17 +4148,6 @@ const USER_FILL_OVERRIDES: Readonly<Record<string, unknown>> = {
sealLocation({ lat: 2.5, lon: 2.5, label: "round-trip home" }),
};
/**
* v1.42 (#615) — readable setting columns the restore seals into another
* column and writes empty. The value they held is compared through the sealed
* column (`homeLocationEncrypted`).
*/
const SEALED_ON_RESTORE: Readonly<Record<string, string>> = {
homeLat: "sealed into homeLocationEncrypted",
homeLon: "sealed into homeLocationEncrypted",
homeLabel: "sealed into homeLocationEncrypted",
};
/**
* Columns of the account row the restore writes for reasons of its own, so
* "unchanged on the target" does not apply to them.
@@ -4302,15 +4284,8 @@ describe("the account's own settings survive a real restore", () => {
})) as unknown as Record<string, unknown>;
const lost = [...settings].filter(
(name) =>
!Object.hasOwn(SEALED_ON_RESTORE, name) &&
opened(after[name]) !== opened(source[name]),
(name) => opened(after[name]) !== opened(source[name]),
);
// The readable home columns come back empty by design; the home itself
// came back sealed, compared above through `homeLocationEncrypted`.
for (const name of Object.keys(SEALED_ON_RESTORE)) {
expect(after[name], name).toBeNull();
}
expect(
lost,
"these settings left with a value and came back without it",
@@ -0,0 +1,245 @@
/**
* Migration 0409 drops the readable location columns, and refuses to run
* while any row still holds one.
*
* The suite's database is already migrated, so each case first puts the
* v1.42 shape back (the three readable column sets and the 0382 partial
* indexes) inside a transaction, then runs the migration's own statements
* against it. Every case rolls back, so the database leaves as it came.
*
* - A readable home, period or day aborts the migration with a message that
* counts each kind, and nothing is lost: the columns and the value are
* still there afterwards.
* - Without readable values it drops the nine columns and the three
* indexes, and the sealed copies are untouched.
*/
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { Prisma } from "@/generated/prisma/client";
import { getPrismaClient, truncateAllTables } from "./setup";
const prisma = getPrismaClient();
const USER = "drop-plaintext-location";
const ROLLBACK = new Error("roll back the case");
const MIGRATION = readFileSync(
resolve(
process.cwd(),
"prisma",
"migrations",
"0409_drop_plaintext_location",
"migration.sql",
),
"utf8",
);
/**
* The migration as separate statements. A `DO $$ … $$` block carries
* semicolons of its own, so the split honours dollar quoting; comment lines
* are dropped first.
*/
function statements(sql: string): string[] {
const text = sql
.split("\n")
.filter((line) => !line.trimStart().startsWith("--"))
.join("\n");
const out: string[] = [];
let current = "";
let inDollar = false;
for (let i = 0; i < text.length; i += 1) {
if (text.startsWith("$$", i)) {
inDollar = !inDollar;
current += "$$";
i += 1;
continue;
}
const ch = text[i];
if (ch === ";" && !inDollar) {
if (current.trim()) out.push(current.trim());
current = "";
continue;
}
current += ch;
}
if (current.trim()) out.push(current.trim());
return out;
}
/** The readable columns and partial indexes as v1.42 left them. */
async function restoreV142Shape(tx: Prisma.TransactionClient): Promise<void> {
await tx.$executeRawUnsafe(`ALTER TABLE "users"
ADD COLUMN "home_lat" DOUBLE PRECISION,
ADD COLUMN "home_lon" DOUBLE PRECISION,
ADD COLUMN "home_label" TEXT`);
await tx.$executeRawUnsafe(`ALTER TABLE "environment_travel_locations"
ADD COLUMN "lat" DOUBLE PRECISION,
ADD COLUMN "lon" DOUBLE PRECISION,
ADD COLUMN "label" TEXT`);
await tx.$executeRawUnsafe(`ALTER TABLE "environment_contexts"
ADD COLUMN "lat" DOUBLE PRECISION,
ADD COLUMN "lon" DOUBLE PRECISION,
ADD COLUMN "location_label" TEXT`);
await tx.$executeRawUnsafe(
`CREATE INDEX "users_home_location_plain_idx" ON "users"("id") WHERE "home_lat" IS NOT NULL`,
);
await tx.$executeRawUnsafe(
`CREATE INDEX "environment_travel_locations_plain_location_idx" ON "environment_travel_locations"("id") WHERE "lat" IS NOT NULL`,
);
await tx.$executeRawUnsafe(
`CREATE INDEX "environment_contexts_plain_location_idx" ON "environment_contexts"("id") WHERE "lat" IS NOT NULL`,
);
}
async function runMigration(tx: Prisma.TransactionClient): Promise<void> {
for (const statement of statements(MIGRATION)) {
await tx.$executeRawUnsafe(statement);
}
}
async function columnCount(tx: Prisma.TransactionClient): Promise<number> {
const rows = await tx.$queryRaw<{ n: bigint }[]>`
SELECT count(*) AS n FROM information_schema.columns
WHERE (table_name = 'users' AND column_name IN ('home_lat', 'home_lon', 'home_label'))
OR (table_name = 'environment_travel_locations' AND column_name IN ('lat', 'lon', 'label'))
OR (table_name = 'environment_contexts' AND column_name IN ('lat', 'lon', 'location_label'))`;
return Number(rows[0].n);
}
async function plainIndexCount(tx: Prisma.TransactionClient): Promise<number> {
const rows = await tx.$queryRaw<{ n: bigint }[]>`
SELECT count(*) AS n FROM pg_indexes
WHERE indexname IN (
'users_home_location_plain_idx',
'environment_travel_locations_plain_location_idx',
'environment_contexts_plain_location_idx'
)`;
return Number(rows[0].n);
}
/** Run `body` in a transaction that always rolls back. */
async function inRolledBackCase(
body: (tx: Prisma.TransactionClient) => Promise<void>,
): Promise<void> {
await expect(
prisma.$transaction(async (tx) => {
await body(tx);
throw ROLLBACK;
}),
).rejects.toBe(ROLLBACK);
}
/** The migration's outcome inside a savepoint, so the case can look after it. */
async function attempt(tx: Prisma.TransactionClient): Promise<Error | null> {
await tx.$executeRawUnsafe("SAVEPOINT migration_0409");
try {
await runMigration(tx);
await tx.$executeRawUnsafe("RELEASE SAVEPOINT migration_0409");
return null;
} catch (err) {
await tx.$executeRawUnsafe("ROLLBACK TO SAVEPOINT migration_0409");
return err instanceof Error ? err : new Error(String(err));
}
}
beforeAll(async () => {
await truncateAllTables(prisma);
await prisma.user.create({
data: { id: USER, username: USER, email: `${USER}@example.test` },
});
});
afterAll(async () => {
await truncateAllTables(prisma);
});
describe("migration 0409 (drop the readable location columns)", () => {
it("is split into its guard and its drops", () => {
const parts = statements(MIGRATION);
expect(parts[0]).toMatch(/^DO \$\$/);
expect(parts[0]).toContain("RAISE EXCEPTION");
// The guard runs before anything is dropped.
expect(parts.slice(1).every((s) => /^(DROP|ALTER)/.test(s))).toBe(true);
expect(parts.length).toBeGreaterThan(3);
});
it("stops on a readable home, names the counts, and loses nothing", async () => {
await inRolledBackCase(async (tx) => {
await restoreV142Shape(tx);
await tx.$executeRawUnsafe(
`UPDATE "users" SET "home_lat" = 51.5, "home_lon" = 7.2, "home_label" = 'Bochum' WHERE "id" = $1`,
USER,
);
const error = await attempt(tx);
expect(error?.message).toMatch(/HealthLog migration 0409 stopped/);
expect(error?.message).toMatch(
/1 home location\(s\), 0 location period\(s\) and 0 daily location\(s\)/,
);
// Nothing was dropped and the value is where it was.
expect(await columnCount(tx)).toBe(9);
expect(await plainIndexCount(tx)).toBe(3);
const rows = await tx.$queryRawUnsafe<{ home_lat: number }[]>(
`SELECT "home_lat" FROM "users" WHERE "id" = $1`,
USER,
);
expect(rows[0].home_lat).toBe(51.5);
});
});
it("counts readable periods and days as well", async () => {
await inRolledBackCase(async (tx) => {
await restoreV142Shape(tx);
await tx.$executeRawUnsafe(
`INSERT INTO "environment_travel_locations" ("id", "user_id", "start_date", "end_date", "lat", "lon", "label", "updated_at")
VALUES ('t1', $1, '2026-05-01', '2026-05-03', 38.7, -9.1, 'Lisbon', now()),
('t2', $1, '2026-06-01', '2026-06-03', 41.4, 2.2, 'Barcelona', now())`,
USER,
);
await tx.$executeRawUnsafe(
`INSERT INTO "environment_contexts" ("id", "user_id", "date", "source", "lat", "lon", "location_label", "updated_at")
VALUES ('d1', $1, '2026-05-01', 'TRAVEL', 38.7, -9.1, 'Lisbon', now())`,
USER,
);
const error = await attempt(tx);
expect(error?.message).toMatch(
/0 home location\(s\), 2 location period\(s\) and 1 daily location\(s\)/,
);
expect(await columnCount(tx)).toBe(9);
});
});
it("drops the columns and indexes when no readable value is left, sealed rows untouched", async () => {
await inRolledBackCase(async (tx) => {
await restoreV142Shape(tx);
const sealed = new Uint8Array([2, 1, 2, 3]);
await tx.environmentTravelLocation.create({
data: {
id: "sealed-period",
userId: USER,
startDate: "2026-07-01",
endDate: "2026-07-02",
locationEncrypted: sealed,
},
});
expect(await attempt(tx)).toBeNull();
expect(await columnCount(tx)).toBe(0);
expect(await plainIndexCount(tx)).toBe(0);
const kept = await tx.environmentTravelLocation.findUnique({
where: { id: "sealed-period" },
});
expect(Buffer.from(kept!.locationEncrypted!)).toEqual(
Buffer.from(sealed),
);
});
});
it("leaves the migrated database as it found it", async () => {
expect(await columnCount(prisma as Prisma.TransactionClient)).toBe(0);
expect(await plainIndexCount(prisma as Prisma.TransactionClient)).toBe(0);
});
});
@@ -5,16 +5,14 @@
* - setting a home and adding a travel period store the coarse value, and
* the home answer carries it in the same shape as before (numbers);
* - a restore of a file written with 2-decimal coordinates stores them
* coarse;
* - the rounding block of migration 0361 coarsens rows written before it,
* and running it twice changes nothing more.
* coarse.
*
* v1.42 (#615): the locations are stored sealed, so the coarse value is
* asserted on the opened sealed copy, and the readable columns are empty. The
* rounding still happens before sealing; nothing finer is ever stored.
* asserted on the opened sealed copy. The rounding happens before sealing;
* nothing finer is ever stored. Since v1.43 (migration 0409) the readable
* columns no longer exist, so the rounding block of migration 0361, which
* worked on them, has nothing left to test against.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
@@ -100,11 +98,6 @@ describe("environment coordinates at one decimal", () => {
const user = await getPrismaClient().user.findUniqueOrThrow({
where: { id: OWNER_ID },
});
expect([user.homeLat, user.homeLon, user.homeLabel]).toEqual([
null,
null,
null,
]);
expect(openLocation(user.homeLocationEncrypted!)).toEqual({
lat: 52.5,
lon: 13.4,
@@ -125,9 +118,6 @@ describe("environment coordinates at one decimal", () => {
const stored = await getPrismaClient().environmentTravelLocation.findMany({
where: { userId: OWNER_ID },
});
expect(stored.map((t) => [t.lat, t.lon, t.label])).toEqual([
[null, null, null],
]);
expect(stored.map((t) => openLocation(t.locationEncrypted!))).toEqual([
{ lat: 48.1, lon: 11.6, label: "Munich, Germany" },
]);
@@ -169,68 +159,5 @@ describe("environment coordinates at one decimal", () => {
expect(days.map((d) => openLocation(d.locationEncrypted!))).toEqual([
{ lat: 48.1, lon: 11.6, label: "Munich, Germany" },
]);
expect(days.map((d) => [d.lat, d.lon, d.locationLabel])).toEqual([
[null, null, null],
]);
});
it("coarsens rows written before migration 0361, idempotently", async () => {
const prisma = getPrismaClient();
await prisma.user.update({
where: { id: OWNER_ID },
data: { homeLat: 52.52, homeLon: 13.38 },
});
await prisma.environmentTravelLocation.create({
data: {
userId: OWNER_ID,
startDate: "2026-08-01",
endDate: "2026-08-10",
lat: 48.14,
lon: 11.58,
label: "Munich, Germany",
},
});
await prisma.environmentContext.create({
data: {
userId: OWNER_ID,
date: "2026-08-02",
lat: -33.87,
lon: 151.21,
locationLabel: "Sydney, Australia",
source: "TRAVEL",
},
});
const sql = readFileSync(
join(
__dirname,
"../../prisma/migrations/0361_encrypt_route_geometry_and_practitioner_contact/migration.sql",
),
"utf8",
);
const statements = sql
.slice(sql.indexOf('UPDATE "users"'))
.split(";")
.map((s) => s.trim())
.filter((s) => s.length > 0);
expect(statements).toHaveLength(3);
for (let pass = 0; pass < 2; pass++) {
for (const statement of statements) {
await prisma.$executeRawUnsafe(statement);
}
const user = await prisma.user.findUniqueOrThrow({
where: { id: OWNER_ID },
});
expect([user.homeLat, user.homeLon]).toEqual([52.5, 13.4]);
const travel = await prisma.environmentTravelLocation.findFirstOrThrow({
where: { userId: OWNER_ID },
});
expect([travel.lat, travel.lon]).toEqual([48.1, 11.6]);
const day = await prisma.environmentContext.findFirstOrThrow({
where: { userId: OWNER_ID },
});
expect([day.lat, day.lon]).toEqual([-33.9, 151.2]);
}
});
});
@@ -1,13 +1,11 @@
/**
* v1.42 (#615) — the environment locations sealed at rest, against real
* Postgres with the migrated schema (0381, 0382).
* Postgres with the migrated schema (0381, 0382, and 0409, which dropped the
* readable location columns).
*
* - the writers (home, period) store only the sealed copy, the overview
* answers readable, and the air-quality switch round-trips through its
* route;
* - the boot-time backfill finds the accounts still holding a readable
* home, period or day, seals each, clears the readable columns, and
* converges (a second pass and a second discovery find nothing);
* - the backup carries the location readable in a portable file and sealed
* in a disaster-recovery file, and both restore to a location that opens
* to the same coarse place, with the air-quality values of the day;
@@ -22,10 +20,6 @@ import { POST as postTravel } from "@/app/api/environment/travel/route";
import { PATCH as patchPreferences } from "@/app/api/environment/preferences/route";
import { GET as getOverview } from "@/app/api/environment/route";
import { setGlobalBoss } from "@/lib/jobs/boss-instance";
import {
enqueueBootTimeFreeTextEncryptionBackfill,
runFreeTextEncryptionBackfillForUser,
} from "@/lib/jobs/free-text-encryption-backfill";
import { openLocation, sealLocation } from "@/lib/environment/location-cipher";
import {
buildEnvironmentBackupSection,
@@ -134,19 +128,18 @@ describe("environment writers and the overview", () => {
).toBe(201);
const user = await prisma.user.findUniqueOrThrow({ where: { id: OWNER } });
expect([user.homeLat, user.homeLon, user.homeLabel]).toEqual([
null,
null,
null,
]);
expect(user).not.toHaveProperty("homeLat");
const travel = await prisma.environmentTravelLocation.findFirstOrThrow({
where: { userId: OWNER },
});
expect([travel.lat, travel.lon, travel.label]).toEqual([null, null, null]);
expect(travel).not.toHaveProperty("lat");
// No readable trace of either place anywhere in the two rows.
expect(
JSON.stringify({ ...user, homeLocationEncrypted: null }),
).not.toMatch(/Bochum/);
expect(JSON.stringify({ ...travel, locationEncrypted: null })).not.toMatch(
/Lisbon/,
);
const overview = (await (await getOverview()).json()).data;
expect(overview.home).toMatchObject({
@@ -231,93 +224,6 @@ describe("environment writers and the overview", () => {
});
});
describe("the encryption backfill (real Postgres)", () => {
it("discovers, seals, clears and converges", async () => {
const prisma = getPrismaClient();
await seedUser("env-other");
const stamp = new Date("2026-03-01T10:00:00.000Z");
await prisma.user.update({
where: { id: OWNER },
data: { homeLat: 51.5, homeLon: 7.2, homeLabel: "Bochum, Germany" },
});
await prisma.environmentTravelLocation.create({
data: {
userId: OWNER,
startDate: "2026-08-01",
endDate: "2026-08-10",
lat: 38.7,
lon: -9.1,
label: "Lisbon, Portugal",
updatedAt: stamp,
},
});
await prisma.environmentContext.create({
data: {
userId: OWNER,
date: "2026-08-02",
lat: 38.7,
lon: -9.1,
locationLabel: "Lisbon, Portugal",
source: "TRAVEL",
},
});
const sent: string[] = [];
setGlobalBoss({
send: async (_q: string, payload: { userId?: string }) => {
if (payload.userId) sent.push(payload.userId);
return "job";
},
} as never);
const discovery = await enqueueBootTimeFreeTextEncryptionBackfill();
expect(discovery.error).toBeNull();
expect(sent).toEqual([OWNER]);
const summary = await runFreeTextEncryptionBackfillForUser(OWNER);
expect(summary).toMatchObject({
homeLocationsMigrated: 1,
travelLocationsMigrated: 1,
dayLocationsMigrated: 1,
});
const user = await prisma.user.findUniqueOrThrow({ where: { id: OWNER } });
expect(user.homeLat).toBeNull();
expect(openLocation(user.homeLocationEncrypted!)).toEqual({
lat: 51.5,
lon: 7.2,
label: "Bochum, Germany",
});
const travel = await prisma.environmentTravelLocation.findFirstOrThrow({
where: { userId: OWNER },
});
expect(travel.lat).toBeNull();
expect(travel.updatedAt).toEqual(stamp);
expect(openLocation(travel.locationEncrypted!).label).toBe(
"Lisbon, Portugal",
);
const day = await prisma.environmentContext.findFirstOrThrow({
where: { userId: OWNER },
});
expect([day.lat, day.lon, day.locationLabel]).toEqual([null, null, null]);
expect(openLocation(day.locationEncrypted!).lat).toBe(38.7);
// Converged: nothing readable is left to find.
expect(await runFreeTextEncryptionBackfillForUser(OWNER)).toMatchObject({
homeLocationsMigrated: 0,
travelLocationsMigrated: 0,
dayLocationsMigrated: 0,
});
sent.length = 0;
await enqueueBootTimeFreeTextEncryptionBackfill();
expect(sent).toEqual([]);
const remaining = await prisma.$queryRaw<{ n: bigint }[]>`
SELECT (SELECT count(*) FROM users WHERE home_lat IS NOT NULL)
+ (SELECT count(*) FROM environment_travel_locations WHERE lat IS NOT NULL)
+ (SELECT count(*) FROM environment_contexts WHERE lat IS NOT NULL) AS n`;
expect(Number(remaining[0].n)).toBe(0);
});
});
describe("the environment backup with sealed locations", () => {
async function seedDay() {
const prisma = getPrismaClient();
@@ -383,7 +289,7 @@ describe("the environment backup with sealed locations", () => {
const restored = await prisma.environmentContext.findFirstOrThrow({
where: { userId: OWNER },
});
expect([restored.lat, restored.locationLabel]).toEqual([null, null]);
expect(restored).not.toHaveProperty("lat");
expect(openLocation(restored.locationEncrypted!)).toEqual({
lat: 38.7,
lon: -9.1,
@@ -146,9 +146,6 @@ describe("environment context — the write the enum drift blocked", () => {
create: {
userId: user.id,
date,
lat: 52.5,
lon: 13.4,
locationLabel: "Somewhere",
source,
tempMean: 12.5,
fetchedAt: new Date(),
@@ -174,9 +171,6 @@ describe("environment context — the write the enum drift blocked", () => {
create: {
userId: user.id,
date: "2026-03-01",
lat: 52.5,
lon: 13.4,
locationLabel: "Somewhere",
source: "HOME",
fetchedAt: new Date(),
},
@@ -200,9 +194,6 @@ describe("environment context — the write the enum drift blocked", () => {
data: {
userId: user.id,
date: "2026-04-01",
lat: 52.5,
lon: 13.4,
locationLabel: "Somewhere",
source: "TRAVEL",
fetchedAt: new Date(),
},