diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 71a91a954..fe7ca0be4 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/src/lib/openapi/routes/cycle.ts b/src/lib/openapi/routes/cycle.ts index 903a8cd93..e32173f30 100644 --- a/src/lib/openapi/routes/cycle.ts +++ b/src/lib/openapi/routes/cycle.ts @@ -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(), diff --git a/src/lib/openapi/routes/sync.ts b/src/lib/openapi/routes/sync.ts index 2f767327b..33071b202 100644 --- a/src/lib/openapi/routes/sync.ts +++ b/src/lib/openapi/routes/sync.ts @@ -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 = { @@ -264,9 +311,9 @@ export const syncPaths: NonNullable = { "/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, },