mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
The nightly folds soft-deleted every raw Apple Health sample they folded into an hourly or daily stats row and kept it for 75 days. Nothing ever read those rows as deletions; they only held a re-upload of the sample off, and they were most of the measurements table and its indexes. Ingest now refuses such a re-upload directly: a raw sample of a dense or mean type, old enough to have been folded, whose hour or day a live stats row covers, is a duplicate with reason folded_window. The batch route and the Apple Health export import apply the same rule. With that in place the dense retention and the daily-mean consolidation delete the raw rows outright, and a new compaction-tombstone-purge job removes the backlog: 5,000 rows a batch, one account at a time under the restore lock, a short pause between batches, resumable because the predicate is idempotent, queued at every boot and continuing itself while it makes progress. A person's own deletions are never in its predicate. The sync feed leaves compaction tombstones out and a restore does not write them back; all three classify through the same function. The batch route answers an exact re-sent sample without a transaction: one read over the batch's sample ids, live or deleted, decides what the reconciler would have decided, at the same point in the loop, so every status, reason, counter and the cross-source merge stay as they were. A batch of duplicates only no longer queues PR detection, a reminder sweep or the detection audit row. Caches are evicted only for a foreground or manual sync or a batch that wrote a hand-entered reading, and marked stale otherwise; the targets cache follows the same split. Also: - POST /api/admin/maintenance/measurements queues VACUUM (ANALYZE) and a REINDEX INDEX CONCURRENTLY per index, largest first, on a connection of its own; it refuses while the purge runs, drops invalid leftover copies and stops at the first failed rebuild. Runbook in docs/ops. - The pure volume queues keep their finished jobs for 96 hours instead of seven days, never less than the failure window plus a day. - work_mem is set per app connection from DATABASE_WORK_MEM (16MB), also when the statement timeout is disabled. - The PR-detection fallback tick re-scans only accounts whose readings or workouts changed since the previous tick. - Type discovery for the status refill walks the live-rows index as a loose index scan instead of grouping every live row. - The host metrics chart polls every five minutes, like its sampler. - The batch route records per-type HealthKit arrivals.
652 lines
23 KiB
TypeScript
652 lines
23 KiB
TypeScript
/**
|
|
* `GET /api/sync/changes` — multi-domain delta feed (v1.7.0).
|
|
*
|
|
* The incremental catch-up feed for paired clients. After the first-pair
|
|
* backfill (which uses the batch endpoints), the client drains this feed
|
|
* to pick up server-side changes — including deletions, which surface as
|
|
* tombstones because the user-facing DELETE routes on every served
|
|
* domain now soft-delete (set `deletedAt` + bump `syncVersion`) rather
|
|
* than hard-delete.
|
|
*
|
|
* Domains served: `measurements`, `mood`, `intakes`, `cycleDays`,
|
|
* `cycles` (the last two added v1.15.0; `cycleDays` tombstones key on
|
|
* `externalId` when present else `id`, `cycles` on `id`). The iOS consumer
|
|
* is measurements-only this cycle (iOS-coord
|
|
* `v1.7.0-ios-offline-sync-answers.md` §7.1); mood + intakes are
|
|
* forward-prep so the contract is complete + tested before the
|
|
* multi-domain consumer lands.
|
|
*
|
|
* Contract:
|
|
* - One opaque multi-domain keyset cursor wrapping a per-domain
|
|
* `(updatedAt, id)` watermark; the client treats it as fully opaque
|
|
* (echo, never parse — iOS-coord §7.6). `limit` default 200, hard
|
|
* cap 500, applied PER DOMAIN.
|
|
* - Each domain block carries `tombstones` (soft-deleted rows) AND
|
|
* `upserts` (live rows). The client MUST apply tombstones before
|
|
* upserts within each domain to avoid resurrecting a row whose
|
|
* delete and a later re-insert both fall in the page.
|
|
* - Tombstone identity per the iOS §7.3 table: measurements key on
|
|
* `externalId` (the cross-device key); mood + intakes key on the
|
|
* server `id` (the client mirrors those by server id).
|
|
* - `hasMore` is true when ANY served domain still has rows past its
|
|
* page; the next `cursor` advances every domain that returned rows.
|
|
* When `hasMore` is false the client is caught up as of `serverNow`.
|
|
* - `cursorExpired: true` when ANY domain watermark predates the
|
|
* tombstone-retention horizon — the client drops its cursor and does
|
|
* a clean initial sync (a deletion older than retention may have
|
|
* been pruned, so an incremental delta could silently miss it) — or
|
|
* when the cursor was issued before the account's `syncResetAt` (a
|
|
* backup restore or a delete-all-data since, see the schema note).
|
|
* - `syncVersion` is echoed per row so the client can keep its mirror's
|
|
* version monotonic.
|
|
* - The `cycleDays` / `cycles` blocks are MODULE-GATED: an account with
|
|
* cycle tracking off gets both blocks empty (never a 403 — see the
|
|
* gate note in the handler) and their cursor watermarks untouched.
|
|
*
|
|
* `apiHandler` + `requireAuth` (cookie OR Bearer; iOS uses Bearer).
|
|
* Read-only — no idempotency, no write side-effect (unlike the legacy
|
|
* `/api/sync/state` checkpoint bump). The cursor is owned by the client.
|
|
*/
|
|
import type { NextRequest } from "next/server";
|
|
import { z } from "zod/v4";
|
|
|
|
import { prisma } from "@/lib/db";
|
|
import {
|
|
findCompactionTombstones,
|
|
isFoldedWindowCandidate,
|
|
loadFoldTimezone,
|
|
} from "@/lib/measurements/folded-window";
|
|
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
|
import {
|
|
apiError,
|
|
apiSuccess,
|
|
apiValidationError,
|
|
sanitiseZodIssues,
|
|
} from "@/lib/api-response";
|
|
import { annotate } from "@/lib/logging/context";
|
|
import { checkRateLimit } from "@/lib/rate-limit";
|
|
import { readNote } from "@/lib/crypto/note-cipher";
|
|
import { levelAForWire } from "@/lib/mood/level-a";
|
|
import { contextForWire, type MoodContextWire } from "@/lib/mood/context";
|
|
import { TOMBSTONE_RETENTION_DAYS } from "@/lib/auth/native-client";
|
|
import {
|
|
decodeCursor,
|
|
decodeCursorIssuedAt,
|
|
encodeCursor,
|
|
SYNC_DOMAINS,
|
|
type DomainWatermark,
|
|
type SyncCursor,
|
|
type SyncDomain,
|
|
} from "@/lib/sync/cursor";
|
|
import {
|
|
toCycleDayLogDTO,
|
|
toMenstrualCycleDTO,
|
|
dayLogSymptomInclude,
|
|
type CycleDayLogDTO,
|
|
type MenstrualCycleDTO,
|
|
} from "@/lib/cycle/dto";
|
|
import { isModuleEnabled } from "@/lib/modules/gate";
|
|
|
|
const DEFAULT_LIMIT = 200;
|
|
const MAX_LIMIT = 500;
|
|
const DAY_MS = 86_400_000;
|
|
|
|
const querySchema = z.object({
|
|
cursor: z.string().min(1).max(2048).optional(),
|
|
limit: z.coerce.number().int().min(1).max(MAX_LIMIT).optional(),
|
|
});
|
|
|
|
interface MeasurementUpsert {
|
|
id: string;
|
|
externalId: string | null;
|
|
type: string;
|
|
value: number;
|
|
unit: string;
|
|
measuredAt: string;
|
|
source: string;
|
|
notes: string | null;
|
|
syncVersion: number;
|
|
updatedAt: string;
|
|
}
|
|
|
|
interface MeasurementTombstone {
|
|
id: string;
|
|
externalId: string | null;
|
|
syncVersion: number;
|
|
deletedAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
interface MoodUpsert {
|
|
id: string;
|
|
date: string;
|
|
mood: string;
|
|
score: number;
|
|
/**
|
|
* v1.38 — the five level-A self-state values, each 0-10 and each nullable.
|
|
* `a2` is inverse-oriented: a higher number means more stress, and a client
|
|
* that wants "up is better" flips the sign itself rather than the server
|
|
* storing a flipped value.
|
|
*
|
|
* Server-authoritative: `a1` is derived from the five-point label on every
|
|
* write, so a client that sends only `mood` still mirrors a complete row.
|
|
*/
|
|
a1: number | null;
|
|
a2: number | null;
|
|
a3: number | null;
|
|
a4: number | null;
|
|
a5: number | null;
|
|
/**
|
|
* v1.38 — the day's context, or null when the entry carries none. Null and
|
|
* an object of nulls are different answers and the feed keeps them apart.
|
|
*/
|
|
context: MoodContextWire | null;
|
|
tags: string | null;
|
|
note: string | null;
|
|
moodLoggedAt: string;
|
|
source: string;
|
|
syncVersion: number;
|
|
updatedAt: string;
|
|
}
|
|
|
|
interface MoodTombstone {
|
|
id: string;
|
|
syncVersion: number;
|
|
deletedAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
interface IntakeUpsert {
|
|
id: string;
|
|
medicationId: string;
|
|
scheduledFor: string;
|
|
takenAt: string | null;
|
|
skipped: boolean;
|
|
source: string;
|
|
syncVersion: number;
|
|
updatedAt: string;
|
|
}
|
|
|
|
interface IntakeTombstone {
|
|
id: string;
|
|
syncVersion: number;
|
|
deletedAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
// v1.15.0 — cycle domains. cycleDays key tombstones on `externalId` when
|
|
// present (HealthKit-origin rows are externalId-keyed cross-device, like
|
|
// measurements) else the server `id`; cycles key on the server `id`
|
|
// (computed rows carry no externalId).
|
|
interface CycleDayTombstone {
|
|
id: string;
|
|
externalId: string | null;
|
|
syncVersion: number;
|
|
deletedAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
interface CycleTombstone {
|
|
id: string;
|
|
syncVersion: number;
|
|
deletedAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
/**
|
|
* Build the `(updatedAt, id)` keyset predicate for a domain's watermark.
|
|
* Empty object (full scan) when the client has no watermark for it yet.
|
|
*/
|
|
function keysetFilter(wm: DomainWatermark | undefined) {
|
|
if (!wm) return {};
|
|
const at = new Date(wm.updatedAtMs);
|
|
return {
|
|
OR: [
|
|
{ updatedAt: { gt: at } },
|
|
{ AND: [{ updatedAt: at }, { id: { gt: wm.id } }] },
|
|
],
|
|
};
|
|
}
|
|
|
|
export const GET = apiHandler(async (request: NextRequest) => {
|
|
const { user } = await requireAuth();
|
|
|
|
// Pull is cheap + idempotent; a generous per-user bucket caps a runaway
|
|
// drain loop without throttling normal foreground catch-up.
|
|
const rl = await checkRateLimit(`sync:changes:${user.id}`, 120, 60 * 1000);
|
|
if (!rl.allowed) {
|
|
return apiError("Too many sync requests. Please retry later.", 429);
|
|
}
|
|
|
|
const url = new URL(request.url);
|
|
const parsed = querySchema.safeParse({
|
|
cursor: url.searchParams.get("cursor") ?? undefined,
|
|
limit: url.searchParams.get("limit") ?? undefined,
|
|
});
|
|
if (!parsed.success) {
|
|
return apiValidationError(
|
|
"Invalid sync query",
|
|
sanitiseZodIssues(parsed.error.issues),
|
|
422,
|
|
);
|
|
}
|
|
const limit = parsed.data.limit ?? DEFAULT_LIMIT;
|
|
|
|
const serverNow = new Date();
|
|
|
|
// ── module gate: cycle ────────────────────────────────────────────
|
|
// The `cycleDays` / `cycles` domains carry period days and per-day
|
|
// symptom rows — the same data every `/api/cycle/*` route refuses with a
|
|
// 403 when the module is off. Without this the delta feed re-served them
|
|
// ungated to the very same token.
|
|
//
|
|
// OMIT, NOT 403 — deliberately, and unlike the whole-record FHIR export.
|
|
// This feed multiplexes five domains over one cursor: refusing the whole
|
|
// request would wedge measurement / mood / intake catch-up for every
|
|
// account with cycle tracking off, which (the gate delegates to
|
|
// `isCycleEnabled`) is the default for most accounts. A partial payload
|
|
// is the only outcome that keeps the client syncing while still honouring
|
|
// the promise that a disabled module hands out nothing.
|
|
//
|
|
// The two cycle watermarks are deliberately NOT advanced while the module
|
|
// is off (the queries are skipped, so `advanceCursor` sees an empty page
|
|
// and leaves the prior watermark untouched). Re-enabling therefore resumes
|
|
// from where the client actually left off instead of silently skipping the
|
|
// rows that changed while the module was hidden.
|
|
//
|
|
// KNOWN GAP, out of scope here: a client that synced cycle rows BEFORE the
|
|
// module was turned off keeps its stale local mirror. Emitting tombstones
|
|
// for them would be wrong — a tombstone means deleted, and disabling a
|
|
// module must leave the rows intact so re-enabling finds a complete
|
|
// history. Purging a paired client's mirror needs an explicit
|
|
// "module went dark" signal in the feed contract plus client support for
|
|
// it; that is an additive wire change and an iOS-client change, not a
|
|
// server-side gate fix, so it is left for a coordinated contract cycle.
|
|
const cycleEnabled = await isModuleEnabled(user.id, "cycle");
|
|
|
|
// The retention horizon: tombstones older than this may have been
|
|
// pruned by the cleanup job, so a cursor that predates it can no longer
|
|
// be trusted to deliver every deletion incrementally.
|
|
const retentionHorizon = new Date(
|
|
serverNow.getTime() - TOMBSTONE_RETENTION_DAYS * DAY_MS,
|
|
);
|
|
|
|
// A garbage / unparseable / old-format cursor decodes to null → fresh
|
|
// initial sync (every domain scans from the start).
|
|
const cursor: SyncCursor = parsed.data.cursor
|
|
? (decodeCursor(parsed.data.cursor) ?? {})
|
|
: {};
|
|
|
|
// A stale cursor (ANY domain watermark older than retention) forces a
|
|
// clean re-init: a deletion older than retention may already be pruned.
|
|
const pastRetention = SYNC_DOMAINS.some((domain) => {
|
|
const wm = cursor[domain];
|
|
return wm !== undefined && wm.updatedAtMs < retentionHorizon.getTime();
|
|
});
|
|
// A cursor issued before the account's record was last replaced (a backup
|
|
// restore, or the person deleting all their data) cannot be caught up
|
|
// either: the restore wrote rows back with their original `updatedAt`,
|
|
// behind the cursor's watermarks, and removed rows without tombstones. The
|
|
// client re-initialises instead. Compared by the time the cursor was
|
|
// issued, not by its watermarks, which a restore leaves meaningless; a
|
|
// cursor from before the issue time was recorded counts as issued before
|
|
// any reset.
|
|
let beforeReset = false;
|
|
if (parsed.data.cursor) {
|
|
const account = await prisma.user.findUnique({
|
|
where: { id: user.id },
|
|
select: { syncResetAt: true },
|
|
});
|
|
if (account?.syncResetAt) {
|
|
const issuedAt = decodeCursorIssuedAt(parsed.data.cursor);
|
|
beforeReset =
|
|
issuedAt === null || issuedAt < account.syncResetAt.getTime();
|
|
}
|
|
}
|
|
const expired = pastRetention || beforeReset;
|
|
if (expired) {
|
|
annotate({
|
|
action: { name: "sync.changes.pull" },
|
|
meta: {
|
|
cursor_expired: true,
|
|
cursor_expired_reason: beforeReset ? "sync_reset" : "retention",
|
|
returned: 0,
|
|
},
|
|
});
|
|
return apiSuccess({
|
|
serverNow: serverNow.toISOString(),
|
|
cursor: parsed.data.cursor ?? null,
|
|
hasMore: false,
|
|
cursorExpired: true,
|
|
changes: {
|
|
measurements: { upserts: [], tombstones: [] },
|
|
mood: { upserts: [], tombstones: [] },
|
|
intakes: { upserts: [], tombstones: [] },
|
|
cycleDays: { upserts: [], tombstones: [] },
|
|
cycles: { upserts: [], tombstones: [] },
|
|
},
|
|
});
|
|
}
|
|
|
|
// Per-domain keyset walks. Both live and soft-deleted rows are in the
|
|
// same scan — a soft-delete bumps `updatedAt`, so a tombstone is just a
|
|
// row whose `deletedAt` is non-null. Fetch limit+1 to detect `hasMore`
|
|
// without a count query.
|
|
const [measurementRows, moodRows, intakeRows, cycleDayRows, cycleRows] =
|
|
await Promise.all([
|
|
prisma.measurement.findMany({
|
|
where: { userId: user.id, ...keysetFilter(cursor.measurements) },
|
|
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
|
take: limit + 1,
|
|
select: {
|
|
id: true,
|
|
externalId: true,
|
|
type: true,
|
|
value: true,
|
|
unit: true,
|
|
measuredAt: true,
|
|
source: true,
|
|
notes: true,
|
|
notesEncrypted: true,
|
|
syncVersion: true,
|
|
deletedAt: true,
|
|
updatedAt: true,
|
|
},
|
|
}),
|
|
prisma.moodEntry.findMany({
|
|
where: { userId: user.id, ...keysetFilter(cursor.mood) },
|
|
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
|
take: limit + 1,
|
|
select: {
|
|
id: true,
|
|
date: true,
|
|
mood: true,
|
|
score: true,
|
|
moodA1: true,
|
|
stressA2: true,
|
|
energyA3: true,
|
|
connectionA4: true,
|
|
stabilityA5: true,
|
|
context: true,
|
|
tags: true,
|
|
note: true,
|
|
noteEncrypted: true,
|
|
moodLoggedAt: true,
|
|
source: true,
|
|
syncVersion: true,
|
|
deletedAt: true,
|
|
updatedAt: true,
|
|
},
|
|
}),
|
|
prisma.medicationIntakeEvent.findMany({
|
|
where: { userId: user.id, ...keysetFilter(cursor.intakes) },
|
|
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
|
take: limit + 1,
|
|
select: {
|
|
id: true,
|
|
medicationId: true,
|
|
scheduledFor: true,
|
|
takenAt: true,
|
|
skipped: true,
|
|
source: true,
|
|
syncVersion: true,
|
|
deletedAt: true,
|
|
updatedAt: true,
|
|
},
|
|
}),
|
|
// Skipped outright when the module is off — not filtered after the
|
|
// fact — so the disabled rows are never read out of Postgres at all.
|
|
cycleEnabled
|
|
? prisma.cycleDayLog.findMany({
|
|
where: { userId: user.id, ...keysetFilter(cursor.cycleDays) },
|
|
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
|
take: limit + 1,
|
|
include: dayLogSymptomInclude,
|
|
})
|
|
: [],
|
|
cycleEnabled
|
|
? prisma.menstrualCycle.findMany({
|
|
where: { userId: user.id, ...keysetFilter(cursor.cycles) },
|
|
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
|
take: limit + 1,
|
|
})
|
|
: [],
|
|
]);
|
|
|
|
const nextCursor: SyncCursor = { ...cursor };
|
|
let hasMore = false;
|
|
|
|
// ── measurements ──────────────────────────────────────────
|
|
const mHasMore = measurementRows.length > limit;
|
|
const mPage = mHasMore ? measurementRows.slice(0, limit) : measurementRows;
|
|
const measurementUpserts: MeasurementUpsert[] = [];
|
|
const measurementTombstones: MeasurementTombstone[] = [];
|
|
// v1.42 — compaction tombstones are not deletions anyone made. The folds
|
|
// used to soft-delete the raw Apple Health samples they folded into an
|
|
// hourly or daily `stats:` row, and those rows rode this feed as
|
|
// tombstones for 75 days. They are left out now, by the same classifier the
|
|
// backlog purge uses (`folded-window.ts`); the cursor still advances past
|
|
// them, so the keyset walk is unchanged. A person's own deletions are
|
|
// reported exactly as before.
|
|
const compaction = new Set<string>();
|
|
const tombstonesOnPage = mPage.filter((row) => row.deletedAt !== null);
|
|
if (tombstonesOnPage.length > 0) {
|
|
const candidates = tombstonesOnPage.map((row) => ({
|
|
type: row.type,
|
|
source: row.source,
|
|
externalId: row.externalId,
|
|
measuredAt: row.measuredAt,
|
|
deletedAt: row.deletedAt,
|
|
}));
|
|
if (
|
|
candidates.some((row) => isFoldedWindowCandidate(row, row.deletedAt!))
|
|
) {
|
|
const classA = await findCompactionTombstones(
|
|
prisma,
|
|
user.id,
|
|
await loadFoldTimezone(prisma, user.id),
|
|
candidates,
|
|
);
|
|
for (const index of classA) compaction.add(tombstonesOnPage[index].id);
|
|
}
|
|
}
|
|
for (const row of mPage) {
|
|
if (compaction.has(row.id)) continue;
|
|
if (row.deletedAt) {
|
|
measurementTombstones.push({
|
|
id: row.id,
|
|
externalId: row.externalId,
|
|
syncVersion: row.syncVersion,
|
|
deletedAt: row.deletedAt.toISOString(),
|
|
updatedAt: row.updatedAt.toISOString(),
|
|
});
|
|
} else {
|
|
// Every row carries its `source`. RECOVERY_SCORE is written by BOTH the
|
|
// WHOOP-native sync (`source = WHOOP`) and the COMPUTED proxy job
|
|
// (`source = COMPUTED`) for the same day; the client must resolve to ONE
|
|
// canonical recovery series — WHOOP wins when present, COMPUTED is the
|
|
// fallback — and never chart the proxy and the native value as two
|
|
// competing series. The feed faithfully mirrors both rows (a delta-sync
|
|
// mirror cannot drop a row mid-keyset without a tombstone); the
|
|
// canonical pick is the same WHOOP-over-COMPUTED rule the server's
|
|
// wellness tile + doctor PDF apply (`resolveCanonicalRecovery`).
|
|
measurementUpserts.push({
|
|
id: row.id,
|
|
externalId: row.externalId,
|
|
type: row.type,
|
|
value: row.value,
|
|
unit: row.unit,
|
|
measuredAt: row.measuredAt.toISOString(),
|
|
source: row.source,
|
|
notes: readNote(row.notesEncrypted, row.notes),
|
|
syncVersion: row.syncVersion,
|
|
updatedAt: row.updatedAt.toISOString(),
|
|
});
|
|
}
|
|
}
|
|
advanceCursor(nextCursor, "measurements", mPage);
|
|
hasMore = hasMore || mHasMore;
|
|
|
|
// ── mood ──────────────────────────────────────────────────
|
|
const moodHasMore = moodRows.length > limit;
|
|
const moodPage = moodHasMore ? moodRows.slice(0, limit) : moodRows;
|
|
const moodUpserts: MoodUpsert[] = [];
|
|
const moodTombstones: MoodTombstone[] = [];
|
|
for (const row of moodPage) {
|
|
if (row.deletedAt) {
|
|
moodTombstones.push({
|
|
id: row.id,
|
|
syncVersion: row.syncVersion,
|
|
deletedAt: row.deletedAt.toISOString(),
|
|
updatedAt: row.updatedAt.toISOString(),
|
|
});
|
|
} else {
|
|
moodUpserts.push({
|
|
id: row.id,
|
|
date: row.date,
|
|
mood: row.mood,
|
|
score: row.score,
|
|
// Under the same wire keys the write path accepts, so a client reads
|
|
// back exactly what it can post.
|
|
...levelAForWire(row),
|
|
context: row.context ? contextForWire(row.context) : null,
|
|
tags: row.tags,
|
|
note: readNote(row.noteEncrypted, row.note),
|
|
moodLoggedAt: row.moodLoggedAt.toISOString(),
|
|
source: row.source,
|
|
syncVersion: row.syncVersion,
|
|
updatedAt: row.updatedAt.toISOString(),
|
|
});
|
|
}
|
|
}
|
|
advanceCursor(nextCursor, "mood", moodPage);
|
|
hasMore = hasMore || moodHasMore;
|
|
|
|
// ── intakes ───────────────────────────────────────────────
|
|
const intakeHasMore = intakeRows.length > limit;
|
|
const intakePage = intakeHasMore ? intakeRows.slice(0, limit) : intakeRows;
|
|
const intakeUpserts: IntakeUpsert[] = [];
|
|
const intakeTombstones: IntakeTombstone[] = [];
|
|
for (const row of intakePage) {
|
|
if (row.deletedAt) {
|
|
intakeTombstones.push({
|
|
id: row.id,
|
|
syncVersion: row.syncVersion,
|
|
deletedAt: row.deletedAt.toISOString(),
|
|
updatedAt: row.updatedAt.toISOString(),
|
|
});
|
|
} else {
|
|
intakeUpserts.push({
|
|
id: row.id,
|
|
medicationId: row.medicationId,
|
|
scheduledFor: row.scheduledFor.toISOString(),
|
|
takenAt: row.takenAt?.toISOString() ?? null,
|
|
skipped: row.skipped,
|
|
source: row.source,
|
|
syncVersion: row.syncVersion,
|
|
updatedAt: row.updatedAt.toISOString(),
|
|
});
|
|
}
|
|
}
|
|
advanceCursor(nextCursor, "intakes", intakePage);
|
|
hasMore = hasMore || intakeHasMore;
|
|
|
|
// ── cycleDays ─────────────────────────────────────────────
|
|
const cycleDayHasMore = cycleDayRows.length > limit;
|
|
const cycleDayPage = cycleDayHasMore
|
|
? cycleDayRows.slice(0, limit)
|
|
: cycleDayRows;
|
|
const cycleDayUpserts: CycleDayLogDTO[] = [];
|
|
const cycleDayTombstones: CycleDayTombstone[] = [];
|
|
for (const row of cycleDayPage) {
|
|
if (row.deletedAt) {
|
|
cycleDayTombstones.push({
|
|
id: row.id,
|
|
externalId: row.externalId,
|
|
syncVersion: row.syncVersion,
|
|
deletedAt: row.deletedAt.toISOString(),
|
|
updatedAt: row.updatedAt.toISOString(),
|
|
});
|
|
} else {
|
|
cycleDayUpserts.push(toCycleDayLogDTO(row));
|
|
}
|
|
}
|
|
advanceCursor(nextCursor, "cycleDays", cycleDayPage);
|
|
hasMore = hasMore || cycleDayHasMore;
|
|
|
|
// ── cycles ────────────────────────────────────────────────
|
|
const cycleHasMore = cycleRows.length > limit;
|
|
const cyclePage = cycleHasMore ? cycleRows.slice(0, limit) : cycleRows;
|
|
const cycleUpserts: MenstrualCycleDTO[] = [];
|
|
const cycleTombstones: CycleTombstone[] = [];
|
|
for (const row of cyclePage) {
|
|
if (row.deletedAt) {
|
|
cycleTombstones.push({
|
|
id: row.id,
|
|
syncVersion: row.syncVersion,
|
|
deletedAt: row.deletedAt.toISOString(),
|
|
updatedAt: row.updatedAt.toISOString(),
|
|
});
|
|
} else {
|
|
cycleUpserts.push(toMenstrualCycleDTO(row));
|
|
}
|
|
}
|
|
advanceCursor(nextCursor, "cycles", cyclePage);
|
|
hasMore = hasMore || cycleHasMore;
|
|
|
|
annotate({
|
|
action: { name: "sync.changes.pull" },
|
|
meta: {
|
|
measurement_upserts: measurementUpserts.length,
|
|
measurement_tombstones: measurementTombstones.length,
|
|
mood_upserts: moodUpserts.length,
|
|
mood_tombstones: moodTombstones.length,
|
|
intake_upserts: intakeUpserts.length,
|
|
intake_tombstones: intakeTombstones.length,
|
|
cycle_day_upserts: cycleDayUpserts.length,
|
|
cycle_day_tombstones: cycleDayTombstones.length,
|
|
cycle_upserts: cycleUpserts.length,
|
|
cycle_tombstones: cycleTombstones.length,
|
|
cycle_module_enabled: cycleEnabled,
|
|
has_more: hasMore,
|
|
cursor_present: Boolean(parsed.data.cursor),
|
|
},
|
|
});
|
|
|
|
return apiSuccess({
|
|
serverNow: serverNow.toISOString(),
|
|
cursor: encodeCursor(nextCursor, serverNow.getTime()),
|
|
hasMore,
|
|
cursorExpired: false,
|
|
changes: {
|
|
measurements: {
|
|
upserts: measurementUpserts,
|
|
tombstones: measurementTombstones,
|
|
},
|
|
mood: { upserts: moodUpserts, tombstones: moodTombstones },
|
|
intakes: { upserts: intakeUpserts, tombstones: intakeTombstones },
|
|
cycleDays: {
|
|
upserts: cycleDayUpserts,
|
|
tombstones: cycleDayTombstones,
|
|
},
|
|
cycles: { upserts: cycleUpserts, tombstones: cycleTombstones },
|
|
},
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Advance a domain's watermark to the last row of its page. A domain
|
|
* that returned no rows keeps its prior watermark untouched so a future
|
|
* pull does not re-scan from the start.
|
|
*/
|
|
function advanceCursor(
|
|
cursor: SyncCursor,
|
|
domain: SyncDomain,
|
|
page: Array<{ updatedAt: Date; id: string }>,
|
|
): void {
|
|
const last = page[page.length - 1];
|
|
if (last) {
|
|
cursor[domain] = { updatedAtMs: last.updatedAt.getTime(), id: last.id };
|
|
}
|
|
}
|