mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
refactor(sync): move the delta feed's domain pages out of its route
This commit is contained in:
1 parent
1b5e586a06
commit
ad27e5b89d
8 files changed
+722
-568
No files matched your search
@@ -46,16 +46,11 @@
|
||||
* `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.
|
||||
* The pull itself lives in `src/lib/sync/changes/`.
|
||||
*/
|
||||
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,
|
||||
@@ -63,153 +58,17 @@ import {
|
||||
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";
|
||||
import { pullSyncChanges } from "@/lib/sync/changes/pull";
|
||||
|
||||
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;
|
||||
/** v1.42.2 — recorded by the server (`autoMarkTaken`), not the person. */
|
||||
autoRecorded: boolean;
|
||||
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();
|
||||
|
||||
@@ -232,424 +91,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
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,
|
||||
return apiSuccess(
|
||||
await pullSyncChanges({
|
||||
userId: user.id,
|
||||
token: parsed.data.cursor,
|
||||
limit: parsed.data.limit ?? DEFAULT_LIMIT,
|
||||
serverNow: new Date(),
|
||||
}),
|
||||
);
|
||||
|
||||
// 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,
|
||||
autoRecorded: 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,
|
||||
autoRecorded: row.autoRecorded,
|
||||
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 };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user