Files
HealthLog/src/lib/openapi/routes/sync.ts
T
Marc-André Bombeck 35e8f076a2 refactor(openapi,queries): feature-located registries behind thin indexes
The five-thousand-line route registry splits into thirteen domain
modules and the query-key factory into twelve feature files — each
behind an index that preserves exact registration order, proven by a
byte-identical generated contract. The factory lint rule learns the new
home directory.
2026-06-11 00:10:00 +02:00

264 lines
8.6 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. Each GET also advances the server-side `lastSyncedAt` checkpoint and returns the previous value. 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 previous `lastSyncedAt` checkpoint and advances it server-side on each call. 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,
},
},
},
};