mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
git, eslint and vitest each carry an exclusion for local development worktrees, and all three name /.worktrees/ while the convention actually in use is /.wt-*/. Every one of them was therefore walking eleven full copies of the source tree. The consequences differed by tool and none of them announced itself. pnpm lint took ten minutes instead of one and reported warnings from whatever commits those copies happened to sit on. vitest collected duplicate suites that drag in a second React instance, which fails in ways that have nothing to do with the working tree. git left them merely untracked, one git add -A away from committing the repository eleven times over. With the true warning count finally visible, teach no-unused-vars the underscore convention this codebase already follows and drop the five disable directives that convention made redundant. pnpm lint is clean. A non-underscore unused argument still warns; that was checked rather than assumed.
154 lines
5.6 KiB
TypeScript
154 lines
5.6 KiB
TypeScript
/**
|
|
* `GET /api/sync/state` — iOS SyncMode handshake.
|
|
*
|
|
* The iOS SyncMode store reads this endpoint on pair / unpair, on
|
|
* background-to-foreground, and before opening a fresh anchored
|
|
* HealthKit query window. The response is a compact JSON shape iOS
|
|
* compares against its local SwiftData state to decide whether to
|
|
* (a) drain a pending outbox, (b) request a full refresh, or (c) sit
|
|
* quiet.
|
|
*
|
|
* Response shape:
|
|
* {
|
|
* userId,
|
|
* timezone,
|
|
* lastSyncedAt, — User.lastSyncedAt (ISO string or null).
|
|
* Set by the ingest boundaries, not here:
|
|
* this route is a read and writes nothing.
|
|
* serverNow, — `new Date().toISOString()` for clock skew
|
|
* measurements: { lastUpdatedAt, liveCount, tombstonedCount },
|
|
* mood: { lastUpdatedAt, liveCount, tombstonedCount },
|
|
* intakes: { lastUpdatedAt, liveCount, tombstonedCount },
|
|
* sync: { incrementalWindowDays, tombstoneRetentionDays }
|
|
* }
|
|
*
|
|
* Per-domain block: `lastUpdatedAt` = MAX(updated_at) for live rows,
|
|
* `liveCount` = COUNT WHERE deleted_at IS NULL, `tombstonedCount` =
|
|
* COUNT WHERE deleted_at IS NOT NULL.
|
|
*
|
|
* No body. requireAuth() — cookie + Bearer both accepted (iOS uses
|
|
* Bearer per `05-auth-flows.md`).
|
|
*
|
|
* Locked contract: see `.planning/v15-ios-handoff/08-locked-contracts.md`
|
|
* §13 cross-reference. The shape is additive going forward.
|
|
*/
|
|
import type { NextRequest } from "next/server";
|
|
|
|
import { prisma } from "@/lib/db";
|
|
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
|
import { apiSuccess } from "@/lib/api-response";
|
|
import { annotate } from "@/lib/logging/context";
|
|
import {
|
|
NATIVE_REFRESH_TOKEN_DAYS,
|
|
TOMBSTONE_RETENTION_DAYS,
|
|
} from "@/lib/auth/native-client";
|
|
|
|
export const GET = apiHandler(async (_request: NextRequest) => {
|
|
const { user } = await requireAuth();
|
|
|
|
// Per-domain aggregate reads. Each runs against indexed columns (userId,
|
|
// deleted_at / updated_at) so it stays O(rows-for-user) and not
|
|
// O(rows-across-tenants). v1.7.0 adds the mood + intake blocks now that
|
|
// both carry the sync columns.
|
|
const [
|
|
measurementLatest,
|
|
measurementLive,
|
|
measurementTomb,
|
|
moodLatest,
|
|
moodLive,
|
|
moodTomb,
|
|
intakeLatest,
|
|
intakeLive,
|
|
intakeTomb,
|
|
currentUser,
|
|
] = await Promise.all([
|
|
prisma.measurement.findFirst({
|
|
where: { userId: user.id, deletedAt: null },
|
|
orderBy: { updatedAt: "desc" },
|
|
select: { updatedAt: true },
|
|
}),
|
|
prisma.measurement.count({ where: { userId: user.id, deletedAt: null } }),
|
|
prisma.measurement.count({
|
|
where: { userId: user.id, deletedAt: { not: null } },
|
|
}),
|
|
prisma.moodEntry.findFirst({
|
|
where: { userId: user.id, deletedAt: null },
|
|
orderBy: { updatedAt: "desc" },
|
|
select: { updatedAt: true },
|
|
}),
|
|
prisma.moodEntry.count({ where: { userId: user.id, deletedAt: null } }),
|
|
prisma.moodEntry.count({
|
|
where: { userId: user.id, deletedAt: { not: null } },
|
|
}),
|
|
prisma.medicationIntakeEvent.findFirst({
|
|
where: { userId: user.id, deletedAt: null },
|
|
orderBy: { updatedAt: "desc" },
|
|
select: { updatedAt: true },
|
|
}),
|
|
prisma.medicationIntakeEvent.count({
|
|
where: { userId: user.id, deletedAt: null },
|
|
}),
|
|
prisma.medicationIntakeEvent.count({
|
|
where: { userId: user.id, deletedAt: { not: null } },
|
|
}),
|
|
prisma.user.findUnique({
|
|
where: { id: user.id },
|
|
select: { lastSyncedAt: true, timezone: true },
|
|
}),
|
|
]);
|
|
|
|
// The handshake reports the checkpoint; it does not set it. The column is
|
|
// stamped by the ingest boundaries that actually move data (see
|
|
// `markSyncCheckpoint`), so `lastSyncedAt` answers "when did this account
|
|
// last push a batch" rather than "when did a client last ask". NULL still
|
|
// means never synced, and the value can now only trail the truth — the
|
|
// handshake used to advance it past a window the client had not drained.
|
|
const lastSyncedAt = currentUser?.lastSyncedAt ?? null;
|
|
const serverNow = new Date();
|
|
|
|
annotate({
|
|
action: { name: "sync.state" },
|
|
meta: {
|
|
liveCount: measurementLive,
|
|
tombstonedCount: measurementTomb,
|
|
moodLiveCount: moodLive,
|
|
intakeLiveCount: intakeLive,
|
|
lastSyncedAt: lastSyncedAt?.toISOString() ?? null,
|
|
},
|
|
});
|
|
|
|
return apiSuccess({
|
|
userId: user.id,
|
|
timezone: currentUser?.timezone ?? "Europe/Berlin",
|
|
lastSyncedAt: lastSyncedAt?.toISOString() ?? null,
|
|
serverNow: serverNow.toISOString(),
|
|
measurements: {
|
|
lastUpdatedAt: measurementLatest?.updatedAt?.toISOString() ?? null,
|
|
liveCount: measurementLive,
|
|
tombstonedCount: measurementTomb,
|
|
},
|
|
// v1.7.0 — mood + intake summaries, now that both carry the sync
|
|
// columns. Same shape as `measurements` so the client reads them
|
|
// uniformly.
|
|
mood: {
|
|
lastUpdatedAt: moodLatest?.updatedAt?.toISOString() ?? null,
|
|
liveCount: moodLive,
|
|
tombstonedCount: moodTomb,
|
|
},
|
|
intakes: {
|
|
lastUpdatedAt: intakeLatest?.updatedAt?.toISOString() ?? null,
|
|
liveCount: intakeLive,
|
|
tombstonedCount: intakeTomb,
|
|
},
|
|
// v1.7.0 — the sync window iOS should derive its incremental-delta
|
|
// horizon from, rather than hardcoding a constant. `incrementalWindowDays`
|
|
// tracks the native refresh-token lifetime (a device offline longer
|
|
// re-pairs with a full backfill); `tombstoneRetentionDays` is the
|
|
// horizon past which `/api/sync/changes` returns `cursorExpired`.
|
|
sync: {
|
|
incrementalWindowDays: NATIVE_REFRESH_TOKEN_DAYS,
|
|
tombstoneRetentionDays: TOMBSTONE_RETENTION_DAYS,
|
|
},
|
|
});
|
|
});
|