docs(api): declare the two sync domains that were already on the wire

`GET /api/sync/changes` returns five domains — measurements, mood,
intakes, cycleDays, cycles — and has since the cycle module landed. The
registered schema declared three, closed the object and required only
those three, so `cycleDays` and `cycles` did not exist in the contract:
full day-log and cycle payloads, both upserts and tombstones, computed
and serialised on every single pull, invisible to any client generated
from the spec. The body was also invalid against its own schema, since
the object was closed against the two keys it always sends.

The day-log and cycle DTOs were already published for the cycle routes,
so the two upsert arrays reuse them and only the tombstone shapes are
new. The module's own description and the operation summary were stale
in the same direction and now name all five.

This is the two-ended change the contributor notes describe: the writer
shipped, the contract half did not, and no gate noticed because every
gate proves the other end.

No wire change.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 04:05:04 +02:00
1 parent fd8fc0ec0e
commit 0cd996e7de
3 files changed
+151 -12

No files matched your search

+99 -7
View File
@@ -3331,10 +3331,12 @@ paths:
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."
summary: Multi-domain delta feed
description: "Incremental catch-up after the first-pair backfill (never a replacement for it). Pages over one opaque
keyset cursor spanning five domains — measurements, mood, medication intakes, cycle day logs and cycles. Each
domain carries `tombstones` (soft-deleted rows) and `upserts` (live rows); apply tombstones before upserts
within a domain. Tombstone identity is `externalId` for measurements and the server `id` everywhere else.
`cursorExpired: true` forces a clean re-init and the page is empty in every domain."
parameters:
- in: query
name: cursor
@@ -37327,10 +37329,45 @@ components:
- upserts
- tombstones
additionalProperties: false
cycleDays:
type: object
properties:
upserts:
type: array
items:
$ref: "#/components/schemas/CycleDayLogDTO"
tombstones:
type: array
items:
$ref: "#/components/schemas/SyncCycleDayTombstone"
required:
- upserts
- tombstones
additionalProperties: false
description: Cycle day logs. Present on every page, empty arrays when the account has the cycle module off.
cycles:
type: object
properties:
upserts:
type: array
items:
$ref: "#/components/schemas/MenstrualCycleDTO"
tombstones:
type: array
items:
$ref: "#/components/schemas/SyncCycleTombstone"
required:
- upserts
- tombstones
additionalProperties: false
description: Menstrual cycles, observed and forward-predicted. Present on every page, empty arrays when the account has
the cycle module off.
required:
- measurements
- mood
- intakes
- cycleDays
- cycles
additionalProperties: false
required:
- serverNow
@@ -37339,9 +37376,10 @@ components:
- cursorExpired
- changes
additionalProperties: false
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."
description: "Multi-domain delta page: measurements, mood, intakes, cycle day logs and cycles. One opaque multi-domain
keyset cursor; tombstones apply before upserts within each domain. Tombstone identity: measurements key on
externalId, every other domain on server id. All five keys are present on every response, including the
`cursorExpired` short-circuit — a domain with nothing to report sends empty arrays rather than being omitted."
SyncMeasurementUpsert:
type: object
properties:
@@ -37683,6 +37721,60 @@ components:
- updatedAt
additionalProperties: false
description: A soft-deleted medication intake, keyed on server `id`. Apply before upserts within the domain page.
SyncCycleDayTombstone:
type: object
properties:
id:
type: string
externalId:
anyOf:
- type: string
- type: "null"
description: The client's own key for the day log, when it set one.
syncVersion:
type: integer
minimum: 0
maximum: 9007199254740991
deletedAt:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
updatedAt:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
required:
- id
- externalId
- syncVersion
- deletedAt
- updatedAt
additionalProperties: false
description: A soft-deleted cycle day log, keyed on server `id`. Apply before upserts within the domain page.
SyncCycleTombstone:
type: object
properties:
id:
type: string
syncVersion:
type: integer
minimum: 0
maximum: 9007199254740991
deletedAt:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
updatedAt:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
required:
- id
- syncVersion
- deletedAt
- updatedAt
additionalProperties: false
description: A soft-deleted menstrual cycle, keyed on server `id`. Apply before upserts within the domain page.
GetMeasurementsSeriesBatchResponse:
type: object
properties:
+2 -2
View File
@@ -112,7 +112,7 @@ const cycleSymptomDto = z.object({
severity: z.number().int().min(1).max(4).nullable(),
});
const cycleDayLogDto = z
export const cycleDayLogDto = z
.object({
id: z.string(),
date: z.string(),
@@ -145,7 +145,7 @@ const cycleDayLogDto = z
"The canonical day-log row iOS mirrors. `note` is decrypted on read. Soft-deleted rows ride `/api/sync/changes` as tombstones.",
});
const menstrualCycleDto = z
export const menstrualCycleDto = z
.object({
id: z.string(),
startDate: z.string(),
+50 -3
View File
@@ -9,6 +9,7 @@ import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import { measurementResource } from "./measurements";
import { moodContextWire } from "./mood";
import { cycleDayLogDto, menstrualCycleDto } from "./cycle";
import { dataEnvelope, stdResponses } from "./shared";
// ── Sync (v1.7.0 offline / server-optional) ─────────────────────────
@@ -185,6 +186,36 @@ const syncIntakeTombstone = z
"A soft-deleted medication intake, keyed on server `id`. Apply before upserts within the domain page.",
});
const syncCycleDayTombstone = z
.object({
id: z.string(),
externalId: z
.string()
.nullable()
.describe("The client's own key for the day log, when it set one."),
syncVersion: z.number().int().nonnegative(),
deletedAt: z.iso.datetime({ offset: true }),
updatedAt: z.iso.datetime({ offset: true }),
})
.meta({
id: "SyncCycleDayTombstone",
description:
"A soft-deleted cycle day log, keyed on server `id`. Apply before upserts within the domain page.",
});
const syncCycleTombstone = z
.object({
id: z.string(),
syncVersion: z.number().int().nonnegative(),
deletedAt: z.iso.datetime({ offset: true }),
updatedAt: z.iso.datetime({ offset: true }),
})
.meta({
id: "SyncCycleTombstone",
description:
"A soft-deleted menstrual cycle, keyed on server `id`. Apply before upserts within the domain page.",
});
const syncChangesQuery = z
.object({
cursor: z
@@ -233,12 +264,28 @@ const syncChangesResponse = z
upserts: z.array(syncIntakeUpsert),
tombstones: z.array(syncIntakeTombstone),
}),
cycleDays: z
.object({
upserts: z.array(cycleDayLogDto),
tombstones: z.array(syncCycleDayTombstone),
})
.describe(
"Cycle day logs. Present on every page, empty arrays when the account has the cycle module off.",
),
cycles: z
.object({
upserts: z.array(menstrualCycleDto),
tombstones: z.array(syncCycleTombstone),
})
.describe(
"Menstrual cycles, observed and forward-predicted. Present on every page, empty arrays when the account has the cycle module off.",
),
}),
})
.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.",
"Multi-domain delta page: measurements, mood, intakes, cycle day logs and cycles. One opaque multi-domain keyset cursor; tombstones apply before upserts within each domain. Tombstone identity: measurements key on externalId, every other domain on server id. All five keys are present on every response, including the `cursorExpired` short-circuit — a domain with nothing to report sends empty arrays rather than being omitted.",
});
export const syncPaths: NonNullable<ZodOpenApiObject["paths"]> = {
@@ -264,9 +311,9 @@ export const syncPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/sync/changes": {
get: {
tags: ["Sync"],
summary: "Measurements delta feed",
summary: "Multi-domain 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.",
"Incremental catch-up after the first-pair backfill (never a replacement for it). Pages over one opaque keyset cursor spanning five domains — measurements, mood, medication intakes, cycle day logs and cycles. Each domain carries `tombstones` (soft-deleted rows) and `upserts` (live rows); apply tombstones before upserts within a domain. Tombstone identity is `externalId` for measurements and the server `id` everywhere else. `cursorExpired: true` forces a clean re-init and the page is empty in every domain.",
requestParams: {
query: syncChangesQuery,
},