Files
HealthLog/src/lib/openapi/routes/sync.ts
T
Marc-André Bombeck 6ac3a61829 Take the writes off three read routes
A GET must not have side effects — api-handler.ts says so next to
READ_HTTP_METHODS, where an MCP-audience token is admitted on the safe
methods precisely because they are assumed to write nothing. Three routes
broke that: the sync handshake bumped User.lastSyncedAt, the achievements
read inserted unlock rows, and the cycle calendar fired a discarded promise
at the forecast cache so a failed write left no trace anywhere.

Each write moves to the place that causes it.

The sync checkpoint is now stamped by the batch ingest endpoints for
measurements, mood and intakes. The handshake reports it. That also fixes
the watermark: the checkpoint used to advance past a window the client had
not drained, and a client trusting it would skip that window. It can only
trail a real sync now.

The unlock rows move to a nightly achievement-unlock-sweep. The badge grid
never needed them — unlocked comes from the live metrics and the completion
date is derived — so the payload is unchanged; the rows pin that date and
carry it into the backup, and the sweep reaches accounts that never open
the page.

The cycle forecast moves to a nightly cycle-prediction-refresh. The
CyclePrediction row is not a cache of a read: it is the index the reminder
cron scans, so which reminders an account received depended on whether
somebody had opened the calendar. persistPredictionCache no longer
swallows its errors either — the job reports them.
2026-08-02 01:21:03 +02:00

264 lines
8.7 KiB
TypeScript

/**
* OpenAPI route table — offline sync state + changes feed.
*
* Part of the OpenAPI route table; aggregated in `./index.ts`.
* Schemas come from `src/lib/validations/*` where shared with the
* runtime request parsing, so the wire contract stays single-source.
*/
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import { measurementResource } from "./measurements";
import { dataEnvelope, stdResponses } from "./shared";
// ── Sync (v1.7.0 offline / server-optional) ─────────────────────────
const syncStateResponse = z
.object({
userId: z.string(),
timezone: z.string(),
lastSyncedAt: z.iso.datetime({ offset: true }).nullable(),
serverNow: z.iso.datetime({ offset: true }),
measurements: z.object({
lastUpdatedAt: z.iso.datetime({ offset: true }).nullable(),
liveCount: z.number().int().nonnegative(),
tombstonedCount: z.number().int().nonnegative(),
}),
mood: z.object({
lastUpdatedAt: z.iso.datetime({ offset: true }).nullable(),
liveCount: z.number().int().nonnegative(),
tombstonedCount: z.number().int().nonnegative(),
}),
intakes: z.object({
lastUpdatedAt: z.iso.datetime({ offset: true }).nullable(),
liveCount: z.number().int().nonnegative(),
tombstonedCount: z.number().int().nonnegative(),
}),
sync: z
.object({
incrementalWindowDays: z
.number()
.int()
.positive()
.describe(
"Days an incremental delta stays valid; tracks the native refresh-token lifetime. Beyond it a device re-pairs with a full backfill. iOS derives its window from this rather than hardcoding 60.",
),
tombstoneRetentionDays: z
.number()
.int()
.positive()
.describe(
"Horizon past which tombstones may be pruned. A cursor older than this gets `cursorExpired` on `/api/sync/changes`.",
),
})
.describe("Sync-window metadata the client reads instead of hardcoding."),
})
.meta({
id: "SyncStateResponse",
description:
"iOS SyncMode handshake. Reports the server-side `lastSyncedAt` checkpoint, which the batch ingest endpoints set when a client push lands; the handshake itself writes nothing. The cheap 'should I sync?' summary; the durable delta cursor lives on `/api/sync/changes`.",
});
const syncMeasurementUpsert = measurementResource
.extend({
externalId: z
.string()
.nullable()
.describe("Cross-device dedup key (UUID string or `stats:<id>:<date>`)."),
syncVersion: z
.number()
.int()
.positive()
.describe(
"LWW reconciliation counter; echo to keep the mirror monotonic.",
),
updatedAt: z.iso.datetime({ offset: true }),
})
.meta({ id: "SyncMeasurementUpsert" });
const syncMeasurementTombstone = z
.object({
id: z.string(),
externalId: z
.string()
.nullable()
.describe("The identity key the client dedups on for measurements."),
syncVersion: z.number().int().positive(),
deletedAt: z.iso.datetime({ offset: true }),
updatedAt: z.iso.datetime({ offset: true }),
})
.meta({
id: "SyncMeasurementTombstone",
description:
"A soft-deleted measurement. Apply tombstones BEFORE upserts within a page to avoid resurrecting a row.",
});
const syncMoodUpsert = z
.object({
id: z.string(),
date: z.string().describe("YYYY-MM-DD anchored to the row's `tz`."),
mood: z.string(),
score: z.number().int(),
tags: z.string().nullable().describe("JSON array of tag keys, or null."),
note: z.string().nullable(),
moodLoggedAt: z.iso.datetime({ offset: true }),
source: z.string(),
syncVersion: z
.number()
.int()
.nonnegative()
.describe("LWW reconciliation counter; mood is last-writer-wins by it."),
updatedAt: z.iso.datetime({ offset: true }),
})
.meta({ id: "SyncMoodUpsert" });
const syncMoodTombstone = z
.object({
id: z
.string()
.describe("Server id — the identity key the client dedups on for mood."),
syncVersion: z.number().int().nonnegative(),
deletedAt: z.iso.datetime({ offset: true }),
updatedAt: z.iso.datetime({ offset: true }),
})
.meta({
id: "SyncMoodTombstone",
description:
"A soft-deleted mood entry, keyed on server `id`. Apply before upserts within the domain page.",
});
const syncIntakeUpsert = z
.object({
id: z.string(),
medicationId: z.string(),
scheduledFor: z.iso.datetime({ offset: true }),
takenAt: z.iso.datetime({ offset: true }).nullable(),
skipped: z.boolean(),
source: z.string(),
syncVersion: z
.number()
.int()
.nonnegative()
.describe(
"Reconciliation counter. An intake is immutable; a correction is a tombstone + re-insert.",
),
updatedAt: z.iso.datetime({ offset: true }),
})
.meta({ id: "SyncIntakeUpsert" });
const syncIntakeTombstone = z
.object({
id: z
.string()
.describe(
"Server id — the identity key the client dedups on for intakes.",
),
syncVersion: z.number().int().nonnegative(),
deletedAt: z.iso.datetime({ offset: true }),
updatedAt: z.iso.datetime({ offset: true }),
})
.meta({
id: "SyncIntakeTombstone",
description:
"A soft-deleted medication intake, keyed on server `id`. Apply before upserts within the domain page.",
});
const syncChangesQuery = z
.object({
cursor: z
.string()
.min(1)
.max(2048)
.optional()
.describe(
"Opaque multi-domain keyset cursor from the previous page. Treat as fully opaque — echo, never parse. Omit for the initial sync.",
),
limit: z.coerce
.number()
.int()
.min(1)
.max(500)
.optional()
.describe("Page size, default 200, hard cap 500."),
})
.meta({ id: "SyncChangesQuery" });
const syncChangesResponse = z
.object({
serverNow: z.iso.datetime({ offset: true }),
cursor: z
.string()
.nullable()
.describe("Opaque cursor to echo into the next request."),
hasMore: z
.boolean()
.describe("False once the client is caught up as of `serverNow`."),
cursorExpired: z
.boolean()
.describe(
"True when the supplied cursor predates tombstone retention — drop the cursor and do a clean initial sync.",
),
changes: z.object({
measurements: z.object({
upserts: z.array(syncMeasurementUpsert),
tombstones: z.array(syncMeasurementTombstone),
}),
mood: z.object({
upserts: z.array(syncMoodUpsert),
tombstones: z.array(syncMoodTombstone),
}),
intakes: z.object({
upserts: z.array(syncIntakeUpsert),
tombstones: z.array(syncIntakeTombstone),
}),
}),
})
.meta({
id: "SyncChangesResponse",
description:
"Multi-domain delta page (v1.7.0): measurements + mood + intakes. One opaque multi-domain keyset cursor; tombstones apply before upserts within each domain. Tombstone identity: measurements key on externalId, mood + intakes on server id. The iOS consumer is measurements-only this cycle; mood + intakes are forward-prep.",
});
export const syncPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/sync/state": {
get: {
tags: ["Sync"],
summary: "Sync handshake + window metadata",
description:
"Cheap 'should I sync?' summary. Returns the `lastSyncedAt` checkpoint, which the batch ingest endpoints advance when a client push lands; this route is a read and writes nothing. The `sync` block carries the incremental-delta window + tombstone retention so the client reads them rather than hardcoding.",
responses: {
"200": {
description: "Sync state summary.",
content: {
"application/json": {
schema: dataEnvelope(syncStateResponse, "SyncStateEnvelope"),
},
},
},
...stdResponses,
},
},
},
"/api/sync/changes": {
get: {
tags: ["Sync"],
summary: "Measurements delta feed",
description:
"Incremental catch-up after the first-pair backfill (never a replacement for it). Pages over an opaque keyset cursor; each page carries `tombstones` (soft-deleted rows, keyed on `externalId`) and `upserts` (live rows). Apply tombstones before upserts within a page. `cursorExpired: true` forces a clean re-init.",
requestParams: {
query: syncChangesQuery,
},
responses: {
"200": {
description: "Delta page.",
content: {
"application/json": {
schema: dataEnvelope(syncChangesResponse, "SyncChangesEnvelope"),
},
},
},
...stdResponses,
},
},
},
};