diff --git a/package.json b/package.json index a8f6354bb..97e90130a 100644 --- a/package.json +++ b/package.json @@ -53,8 +53,8 @@ "mcp:stdio": "npx tsx src/cli/mcp-stdio.ts" }, "dependencies": { - "@aws-sdk/client-s3": "^3.1152.0", - "@aws-sdk/lib-storage": "3.1152.0", + "@aws-sdk/client-s3": "^3.1153.0", + "@aws-sdk/lib-storage": "3.1153.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -66,7 +66,7 @@ "@prisma/client": "^7.10.1", "@simplewebauthn/browser": "^14.0.0", "@simplewebauthn/server": "^14.0.3", - "@tanstack/react-query": "5.104.4", + "@tanstack/react-query": "5.104.5", "@tanstack/react-virtual": "^3.14.21", "@types/sax": "^1.2.7", "class-variance-authority": "^0.7.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67cd8344b..4f8855321 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,11 +46,11 @@ importers: .: dependencies: '@aws-sdk/client-s3': - specifier: ^3.1152.0 - version: 3.1152.0 + specifier: ^3.1153.0 + version: 3.1153.0 '@aws-sdk/lib-storage': - specifier: 3.1152.0 - version: 3.1152.0(@aws-sdk/client-s3@3.1152.0) + specifier: 3.1153.0 + version: 3.1153.0(@aws-sdk/client-s3@3.1153.0) '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1) @@ -85,8 +85,8 @@ importers: specifier: ^14.0.3 version: 14.0.3 '@tanstack/react-query': - specifier: 5.104.4 - version: 5.104.4(@types/react@19.3.0)(react@19.3.1) + specifier: 5.104.5 + version: 5.104.5(@types/react@19.3.0)(react@19.3.1) '@tanstack/react-virtual': specifier: ^3.14.21 version: 3.14.21(react-dom@19.3.1(react@19.3.1))(react@19.3.1) @@ -303,8 +303,8 @@ packages: resolution: {integrity: sha512-H8BiG/2DnfwydVxD1uDPLa1s1sfhRnVTbD2OwzfTr3WlJzgbIzICDXxBTlTMjbWNNCe4VS9X55fco+JI34L9kw==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-s3@3.1152.0': - resolution: {integrity: sha512-oI1+ZaM6pSOwZazu4nAb7rL8ug7LSjg31tDoJqRB9MooRqqWCf/qvkIZqne1mRA3xK3slg6K0RNWXaJYHbWLvg==} + '@aws-sdk/client-s3@3.1153.0': + resolution: {integrity: sha512-h0VScQZPfBApg1Qi4AedsOUSMZ7m758kZIg0JO0B5HtwSzQhB7Tgh6h1JoGEXELgrWADPusYMpMBDmrRAEx2yw==} engines: {node: '>=20.0.0'} '@aws-sdk/core@3.978.4': @@ -343,11 +343,11 @@ packages: resolution: {integrity: sha512-nHkgkMIC6NHgqfI6UAQq5cCS4bUkNxkT+r9fXCq/X4IKfX/DHGZ8UM3T4b78OXpWK5OlzEzN4kxwyax0W/MUCA==} engines: {node: '>=20.0.0'} - '@aws-sdk/lib-storage@3.1152.0': - resolution: {integrity: sha512-UU1fDLKBd8az+4AmNx30ggnA4D4HolUd1MxT/J6ka76qKcckI0IxjAy6FpX/vwiUf2KDNxxN2YX0cfwjMqzWoQ==} + '@aws-sdk/lib-storage@3.1153.0': + resolution: {integrity: sha512-zshL4tYVwTXfEOQ0iqNsXUPq/0aoZwOHrAsqlBmGcAij1+8qc79rm9LpzBuEV7KWiSMetZ+89n7mSYgcJKFgyA==} engines: {node: '>=20.0.0'} peerDependencies: - '@aws-sdk/client-s3': ^3.1152.0 + '@aws-sdk/client-s3': ^3.1153.0 '@aws-sdk/middleware-sdk-s3@3.972.80': resolution: {integrity: sha512-zX3lXN/9XOUAuGpxgt3wXwZrXhTjQYEgRsqa856PbZWpOtleUWmCMbhlkLfusPuhW24cOC/8JYR8hnRalrrIkQ==} @@ -2856,12 +2856,8 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} - '@smithy/core@3.35.2': - resolution: {integrity: sha512-ejTukBuP/Pg3t9N8Ux+0zKsXeHWm7p6W3HbXFOyltKasSxZwCFmvTut7kBdZSCF3m9y1bXXyVu6p2xuMFcsTyQ==} - engines: {node: '>=18.0.0'} - - '@smithy/core@3.36.1': - resolution: {integrity: sha512-WC5W1Jrsfxu1y6LeqXMie7N14qmcdafij/Haye4feVlyMMXLGpYxBeOX6Ee1iRJbDzGG5LVman8U1rrt9MYFHA==} + '@smithy/core@3.36.0': + resolution: {integrity: sha512-3hJiwG1F5t1SoXqp/yOCmYXziyx7ZVqF63S5W6YU201e/qwVaHPex+E/3vvyY9a3ORq/b+BydeNZpP2saQ9X2g==} engines: {node: '>=18.0.0'} '@smithy/credential-provider-imds@4.5.2': @@ -3008,11 +3004,11 @@ packages: '@tailwindcss/postcss@4.3.3': resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==} - '@tanstack/query-core@5.104.4': - resolution: {integrity: sha512-nU4Yrb8Sjn0DeVb7PKG9uiepNavys3yoxy1FX0hViY4/P6Ng/mBje2SMl7i9812rtyHKtHyqiaC/td6GkI8StA==} + '@tanstack/query-core@5.104.5': + resolution: {integrity: sha512-Q94gI8Opd+CbdVNOdj8ZCyV8WqLYitrDq1t96Xc8WZT6lY0r5iRnJLXYDxMex7EEf2v+mXvn4FcjaBKSxXZQ9A==} - '@tanstack/react-query@5.104.4': - resolution: {integrity: sha512-YD5QkvUNy8Ey3LbOy2vwL97JjoYycwDv++cWAibTe+9BLYvl3c09XpYp5dZ2VcxcguwoDhGF+utYJYb79RB36w==} + '@tanstack/react-query@5.104.5': + resolution: {integrity: sha512-RORgFe052U+93weaNuqg7qHq7vNVNzXN/VrXqD4bbAuQCWUqOl2lV5Ui9flWk5CVvMyL/Bh27QWhzwH0aaOx9g==} peerDependencies: '@types/react': ^18 || ^19 react: ^18 || ^19 @@ -7293,11 +7289,11 @@ snapshots: dependencies: '@aws-sdk/core': 3.978.4 '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.36.1 + '@smithy/core': 3.36.0 '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/client-s3@3.1152.0': + '@aws-sdk/client-s3@3.1153.0': dependencies: '@aws-sdk/checksums': 3.1001.4 '@aws-sdk/core': 3.978.4 @@ -7305,7 +7301,7 @@ snapshots: '@aws-sdk/middleware-sdk-s3': 3.972.80 '@aws-sdk/signature-v4-multi-region': 3.996.48 '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.36.1 + '@smithy/core': 3.36.0 '@smithy/fetch-http-handler': 5.8.0 '@smithy/node-http-handler': 4.12.1 '@smithy/types': 4.19.0 @@ -7316,7 +7312,7 @@ snapshots: '@aws-sdk/types': 3.974.6 '@aws-sdk/xml-builder': 3.972.41 '@aws/lambda-invoke-store': 0.3.0 - '@smithy/core': 3.36.1 + '@smithy/core': 3.36.0 '@smithy/signature-v4': 5.7.4 '@smithy/types': 4.19.0 bowser: 2.14.1 @@ -7326,7 +7322,7 @@ snapshots: dependencies: '@aws-sdk/core': 3.978.4 '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.36.1 + '@smithy/core': 3.36.0 '@smithy/types': 4.19.0 tslib: 2.8.1 @@ -7334,7 +7330,7 @@ snapshots: dependencies: '@aws-sdk/core': 3.978.4 '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.36.1 + '@smithy/core': 3.36.0 '@smithy/fetch-http-handler': 5.8.0 '@smithy/node-http-handler': 4.12.1 '@smithy/types': 4.19.0 @@ -7351,7 +7347,7 @@ snapshots: '@aws-sdk/credential-provider-web-identity': 3.972.81 '@aws-sdk/nested-clients': 3.997.49 '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.36.1 + '@smithy/core': 3.36.0 '@smithy/credential-provider-imds': 4.5.2 '@smithy/types': 4.19.0 tslib: 2.8.1 @@ -7361,7 +7357,7 @@ snapshots: '@aws-sdk/core': 3.978.4 '@aws-sdk/nested-clients': 3.997.49 '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.36.1 + '@smithy/core': 3.36.0 '@smithy/types': 4.19.0 tslib: 2.8.1 @@ -7374,7 +7370,7 @@ snapshots: '@aws-sdk/credential-provider-sso': 3.973.19 '@aws-sdk/credential-provider-web-identity': 3.972.81 '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.36.1 + '@smithy/core': 3.36.0 '@smithy/credential-provider-imds': 4.5.2 '@smithy/types': 4.19.0 tslib: 2.8.1 @@ -7383,7 +7379,7 @@ snapshots: dependencies: '@aws-sdk/core': 3.978.4 '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.36.1 + '@smithy/core': 3.36.0 '@smithy/types': 4.19.0 tslib: 2.8.1 @@ -7393,7 +7389,7 @@ snapshots: '@aws-sdk/nested-clients': 3.997.49 '@aws-sdk/token-providers': 3.1153.0 '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.36.1 + '@smithy/core': 3.36.0 '@smithy/types': 4.19.0 tslib: 2.8.1 @@ -7402,14 +7398,14 @@ snapshots: '@aws-sdk/core': 3.978.4 '@aws-sdk/nested-clients': 3.997.49 '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.36.1 + '@smithy/core': 3.36.0 '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/lib-storage@3.1152.0(@aws-sdk/client-s3@3.1152.0)': + '@aws-sdk/lib-storage@3.1153.0(@aws-sdk/client-s3@3.1153.0)': dependencies: - '@aws-sdk/client-s3': 3.1152.0 - '@smithy/core': 3.35.2 + '@aws-sdk/client-s3': 3.1153.0 + '@smithy/core': 3.36.0 '@smithy/types': 4.19.0 buffer: 5.6.0 events: 3.3.0 @@ -7421,7 +7417,7 @@ snapshots: '@aws-sdk/core': 3.978.4 '@aws-sdk/signature-v4-multi-region': 3.996.48 '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.36.1 + '@smithy/core': 3.36.0 '@smithy/types': 4.19.0 tslib: 2.8.1 @@ -7430,7 +7426,7 @@ snapshots: '@aws-sdk/core': 3.978.4 '@aws-sdk/signature-v4-multi-region': 3.996.48 '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.36.1 + '@smithy/core': 3.36.0 '@smithy/fetch-http-handler': 5.8.0 '@smithy/node-http-handler': 4.12.1 '@smithy/types': 4.19.0 @@ -7448,7 +7444,7 @@ snapshots: '@aws-sdk/core': 3.978.4 '@aws-sdk/nested-clients': 3.997.49 '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.36.1 + '@smithy/core': 3.36.0 '@smithy/types': 4.19.0 tslib: 2.8.1 @@ -9949,37 +9945,32 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} - '@smithy/core@3.35.2': - dependencies: - '@smithy/types': 4.19.0 - tslib: 2.8.1 - - '@smithy/core@3.36.1': + '@smithy/core@3.36.0': dependencies: '@smithy/types': 4.19.0 tslib: 2.8.1 '@smithy/credential-provider-imds@4.5.2': dependencies: - '@smithy/core': 3.36.1 + '@smithy/core': 3.36.0 '@smithy/types': 4.19.0 tslib: 2.8.1 '@smithy/fetch-http-handler@5.8.0': dependencies: - '@smithy/core': 3.36.1 + '@smithy/core': 3.36.0 '@smithy/types': 4.19.0 tslib: 2.8.1 '@smithy/node-http-handler@4.12.1': dependencies: - '@smithy/core': 3.36.1 + '@smithy/core': 3.36.0 '@smithy/types': 4.19.0 tslib: 2.8.1 '@smithy/signature-v4@5.7.4': dependencies: - '@smithy/core': 3.36.1 + '@smithy/core': 3.36.0 '@smithy/types': 4.19.0 tslib: 2.8.1 @@ -10130,11 +10121,11 @@ snapshots: postcss: 8.5.26 tailwindcss: 4.3.3 - '@tanstack/query-core@5.104.4': {} + '@tanstack/query-core@5.104.5': {} - '@tanstack/react-query@5.104.4(@types/react@19.3.0)(react@19.3.1)': + '@tanstack/react-query@5.104.5(@types/react@19.3.0)(react@19.3.1)': dependencies: - '@tanstack/query-core': 5.104.4 + '@tanstack/query-core': 5.104.5 react: 19.3.1 optionalDependencies: '@types/react': 19.3.0 diff --git a/src/__tests__/backfill-preserves-updated-at-guard.test.ts b/src/__tests__/backfill-preserves-updated-at-guard.test.ts index 39fe550ab..7531d6bd0 100644 --- a/src/__tests__/backfill-preserves-updated-at-guard.test.ts +++ b/src/__tests__/backfill-preserves-updated-at-guard.test.ts @@ -25,7 +25,7 @@ * true, only that the set did not change without someone editing this file. */ import { describe, it, expect } from "vitest"; -import { readFileSync, readdirSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; import { join } from "node:path"; import { walkSourceFiles } from "./helpers/source-files"; @@ -84,17 +84,25 @@ function backfillFiles(): string[] { const jobs = readdirSync(join(SRC, "lib/jobs")) .filter((f) => /-backfill\.ts$/.test(f)) .map((f) => `src/lib/jobs/${f}`); + // A provider that carries its own jobs folder (`lib//jobs/`). + const providerJobs = readdirSync(join(SRC, "lib")).flatMap((dir) => { + const jobsDir = join(SRC, "lib", dir, "jobs"); + if (!existsSync(jobsDir)) return []; + return readdirSync(jobsDir) + .filter((f) => /(?:^|-)backfill\.ts$/.test(f)) + .map((f) => `src/lib/${dir}/jobs/${f}`); + }); const scripts = readdirSync(join(ROOT, "scripts")) .filter((f) => /^backfill-.*\.ts$/.test(f)) .map((f) => `scripts/${f}`); - return [...jobs, ...scripts].sort(); + return [...jobs, ...providerJobs, ...scripts].sort(); } /** * Backfill writes that stamp `updatedAt` on purpose, keyed `file > model`. */ const BACKFILL_STAMPS: Record = { - "src/lib/jobs/fitbit-backfill.ts > fitbitConnection": + "src/lib/fitbit/jobs/backfill.ts > fitbitConnection": "the job's own progress on the connection row; not a listed record", "src/lib/jobs/google-health-backfill.ts > googleHealthConnection": "the job's own progress on the connection row; not a listed record", diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 856cb2548..11c241758 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 115, - "total": 133899, + "count": 106, + "total": 122271, "entries": { "app/api/dashboard/summary/route.ts": 1032, "app/api/documents/inbound/route.ts": 811, @@ -64,13 +64,12 @@ "lib/export/backup-plan.ts": 853, "lib/export/coach-backup.ts": 1070, "lib/export/profile-backup.ts": 1019, - "lib/fitbit/client.ts": 1529, - "lib/fitbit/sync-core.ts": 968, + "lib/fitbit/client.ts": 1262, + "lib/fitbit/sync-core.ts": 951, "lib/gamification/achievements-result.ts": 1094, "lib/gamification/achievements.ts": 913, - "lib/google-health/client.ts": 1875, "lib/google-health/mappers.ts": 1295, - "lib/google-health/sync-core.ts": 1634, + "lib/google-health/sync-core.ts": 1621, "lib/idempotency.ts": 879, "lib/illness/correlation.ts": 1059, "lib/import/health-connect/import.ts": 1689, @@ -84,18 +83,13 @@ "lib/insights/mood-aggregates.ts": 1053, "lib/insights/status-cache.ts": 812, "lib/integrations/status.ts": 1445, - "lib/jobs/apple-health-import-worker.ts": 801, - "lib/jobs/backup-restore.ts": 888, - "lib/jobs/coach-nudge.ts": 1089, - "lib/jobs/insight-pregenerate.ts": 1266, - "lib/jobs/medication-intake-import.ts": 822, - "lib/jobs/offhost-backup.ts": 1168, + "lib/jobs/backup-restore.ts": 887, + "lib/jobs/coach-nudge.ts": 1084, + "lib/jobs/insight-pregenerate.ts": 1259, + "lib/jobs/medication-intake-import.ts": 819, + "lib/jobs/offhost-backup.ts": 1147, "lib/jobs/reaction-line.ts": 824, "lib/jobs/reminder/medication-reminder-check.ts": 818, - "lib/jobs/reminder/register-integration-sync.ts": 1051, - "lib/jobs/reminder/register-maintenance.ts": 2197, - "lib/jobs/reminder/register-rollup.ts": 1156, - "lib/jobs/reminder/register-status.ts": 878, "lib/managed-profiles/handover.ts": 986, "lib/mcp/record-search.ts": 1467, "lib/mcp/rich-reads.ts": 1467, @@ -105,18 +99,15 @@ "lib/medications/scheduling/recurrence.ts": 1108, "lib/medications/scheduling/slot-upsert.ts": 926, "lib/notifications/senders/apns.ts": 1100, - "lib/oura/client.ts": 1360, - "lib/oura/sync.ts": 902, - "lib/polar/client.ts": 1010, + "lib/oura/sync.ts": 900, "lib/rollups/measurement-rollups.ts": 1496, "lib/search/search-records.ts": 996, "lib/sharing/grants.ts": 807, - "lib/support/report.ts": 905, + "lib/support/report.ts": 903, "lib/telegram-webhook-handlers.ts": 1341, "lib/validations/inbound-documents.ts": 1130, - "lib/whoop/client.ts": 917, "lib/withings/sync-sleep.ts": 884, - "lib/withings/sync.ts": 978, + "lib/withings/sync.ts": 933, "proxy.ts": 870 } } diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index c2fc2484e..ed5d5955f 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 388, - "total": 137367, + "count": 380, + "total": 133935, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -313,12 +313,11 @@ "lib/export/vaccinations-backup.ts :: restoreVaccinationsData": 216, "lib/export/visits-backup.ts :: restoreVisitsData": 261, "lib/fhir/build-bundle.ts :: buildFhirDocumentBundle": 363, - "lib/fitbit/sync-core.ts :: upsertFitbitMeasurements": 370, + "lib/fitbit/sync-core.ts :: upsertFitbitMeasurements": 356, "lib/gamification/achievements-result.ts :: buildAchievementsResult": 586, - "lib/google-health/client.ts :: forEachDataPointPage": 288, - "lib/google-health/client.ts :: forEachDataPointPage > walk": 223, - "lib/google-health/sync-core.ts :: upsertGoogleHealthMeasurements": 463, - "lib/google-health/sync.ts :: runGoogleHealthSyncCycle": 203, + "lib/google-health/data-points.ts :: forEachDataPointPage": 288, + "lib/google-health/data-points.ts :: forEachDataPointPage > walk": 223, + "lib/google-health/sync-core.ts :: upsertGoogleHealthMeasurements": 453, "lib/illness/correlation.ts :: computeIllnessCorrelation": 233, "lib/import/labs-csv.ts :: importLabsCsv": 309, "lib/import/labs-csv.ts :: importLabsCsv > flush": 231, @@ -348,13 +347,6 @@ "lib/jobs/reaction-line.ts :: runReactionLine": 337, "lib/jobs/reminder/medication-reminder-check.ts :: handleReminderCheck": 619, "lib/jobs/reminder/medication-reminder-check.ts :: handleReminderCheck > withBackgroundEvent(arg)": 614, - "lib/jobs/reminder/register-integration-sync.ts :: enqueueIntegrationSyncBootDiscovery": 224, - "lib/jobs/reminder/register-integration-sync.ts :: registerIntegrationSyncQueues": 320, - "lib/jobs/reminder/register-maintenance.ts :: enqueueMaintenanceBootDiscovery": 232, - "lib/jobs/reminder/register-maintenance.ts :: registerMaintenanceQueues": 997, - "lib/jobs/reminder/register-rollup.ts :: enqueueRollupBootDiscovery": 238, - "lib/jobs/reminder/register-rollup.ts :: registerRollupQueues": 616, - "lib/jobs/reminder/register-status.ts :: registerStatusQueues": 542, "lib/jobs/workout-insight-generate.ts :: runWorkoutInsightGenerate": 324, "lib/labs/biomarker-merge.ts :: runMerge": 254, "lib/labs/ocr-extract.ts :: runOcrExtraction": 207, @@ -389,7 +381,7 @@ "lib/withings/sync-activity.ts :: syncUserActivity": 377, "lib/withings/sync-ecg.ts :: syncUserEcg": 202, "lib/withings/sync-sleep.ts :: syncUserSleep": 414, - "lib/withings/sync.ts :: syncUserMeasurements": 373, + "lib/withings/sync.ts :: syncUserMeasurements": 337, "proxy.ts :: proxy": 499 } } diff --git a/src/__tests__/baselines/test-source-reads.json b/src/__tests__/baselines/test-source-reads.json index ec0b14397..d0d61d7bd 100644 --- a/src/__tests__/baselines/test-source-reads.json +++ b/src/__tests__/baselines/test-source-reads.json @@ -1,7 +1,7 @@ { "$comment": "tests that read a source file by path, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.", - "count": 366, - "total": 366, + "count": 348, + "total": 348, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 1, "__tests__/admin-data-wipe-completeness.test.ts": 1, @@ -302,6 +302,7 @@ "lib/export/__tests__/intraday-profile-backup.test.ts": 1, "lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts": 1, "lib/fitbit/__tests__/sync-module-graph.test.ts": 1, + "lib/fitbit/jobs/__tests__/queues.test.ts": 1, "lib/format/__tests__/bytes.test.ts": 1, "lib/google-health/__tests__/sync-module-graph.test.ts": 1, "lib/http/__tests__/deprecation.test.ts": 1, @@ -313,44 +314,25 @@ "lib/jobs/__tests__/ai-job-capability-inventory.test.ts": 1, "lib/jobs/__tests__/apple-health-import-ecg.test.ts": 1, "lib/jobs/__tests__/apple-health-import-export-date.test.ts": 1, - "lib/jobs/__tests__/apple-health-import-reconcile.test.ts": 1, "lib/jobs/__tests__/apple-health-import-send-policy.test.ts": 1, - "lib/jobs/__tests__/coach-memory-refresh-queue.test.ts": 1, "lib/jobs/__tests__/coach-message-cleanup-queue.test.ts": 1, - "lib/jobs/__tests__/cumulative-pr-rederive-queue.test.ts": 1, "lib/jobs/__tests__/cycle-reminder-queue.test.ts": 1, - "lib/jobs/__tests__/daily-briefing.test.ts": 1, "lib/jobs/__tests__/data-arrival-provider-isolation.test.ts": 1, "lib/jobs/__tests__/data-arrival-queue.test.ts": 1, "lib/jobs/__tests__/data-backup-send-policy.test.ts": 1, - "lib/jobs/__tests__/drain-cumulative-queue.test.ts": 1, - "lib/jobs/__tests__/fitbit-queues.test.ts": 1, "lib/jobs/__tests__/geo-backfill.test.ts": 1, "lib/jobs/__tests__/geolite2-fetch-queue.test.ts": 1, "lib/jobs/__tests__/google-health-queues.test.ts": 1, - "lib/jobs/__tests__/insight-pregenerate.test.ts": 1, - "lib/jobs/__tests__/insight-status-generate.test.ts": 1, - "lib/jobs/__tests__/intake-slot-dedup-queue.test.ts": 1, "lib/jobs/__tests__/integration-backfill-admission.test.ts": 1, "lib/jobs/__tests__/job-failures-retention.test.ts": 1, "lib/jobs/__tests__/job-outcome-source-inventory.test.ts": 1, "lib/jobs/__tests__/mcp-token-cleanup-queue.test.ts": 1, - "lib/jobs/__tests__/mean-consolidation-queue.test.ts": 1, "lib/jobs/__tests__/measurement-reminder-queue.test.ts": 1, - "lib/jobs/__tests__/measurement-tombstone-cleanup.test.ts": 1, - "lib/jobs/__tests__/nightscout-queues.test.ts": 1, - "lib/jobs/__tests__/period-narrative-warm.test.ts": 1, - "lib/jobs/__tests__/queue-policy-tables.test.ts": 1, - "lib/jobs/__tests__/read-side-effect-queues.test.ts": 1, "lib/jobs/__tests__/recovery-score-queue.test.ts": 1, - "lib/jobs/__tests__/sleep-timeline-queues.test.ts": 1, "lib/jobs/__tests__/status-cron-candidates.test.ts": 1, - "lib/jobs/__tests__/step-consolidation-repair-queue.test.ts": 1, "lib/jobs/__tests__/stress-strain-retention-queue.test.ts": 1, - "lib/jobs/__tests__/subsystem-surface.test.ts": 1, "lib/jobs/__tests__/tls-pin-monitor-queue.test.ts": 1, "lib/jobs/__tests__/whoop-queues.test.ts": 1, - "lib/jobs/__tests__/withings-ecg-worker.test.ts": 1, "lib/jobs/__tests__/withings-queues.test.ts": 1, "lib/jobs/__tests__/workout-insight-queue.test.ts": 1, "lib/jobs/reminder/__tests__/integration-cohort-outcomes.test.ts": 1, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 00a4bb08d..dfeee8420 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", - "count": 2950, - "total": 10187, + "count": 2915, + "total": 9853, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -2062,6 +2062,10 @@ "lib/fitbit/__tests__/poll-cohort.test.ts": 1, "lib/fitbit/__tests__/sync-user-fitbit.test.ts": 1, "lib/fitbit/client.ts": 1, + "lib/fitbit/jobs/__tests__/backfill.test.ts": 1, + "lib/fitbit/jobs/backfill.ts": 1, + "lib/fitbit/jobs/oauth-state-cleanup.ts": 1, + "lib/fitbit/jobs/sync-handlers.ts": 2, "lib/fitbit/sync.ts": 1, "lib/format-locale.ts": 1, "lib/format.ts": 4, @@ -2077,7 +2081,6 @@ "lib/google-health/__tests__/sync-intraday-daily-wide.test.ts": 1, "lib/google-health/__tests__/sync-intraday-overlap.test.ts": 1, "lib/google-health/__tests__/sync-sleep-morning-refresh.test.ts": 1, - "lib/google-health/client.ts": 1, "lib/google-health/sync-core.ts": 2, "lib/google-health/sync-metrics.ts": 1, "lib/i18n/__tests__/relative-time-updated.test.ts": 2, @@ -2285,59 +2288,33 @@ "lib/integrations/metric-freshness.ts": 1, "lib/integrations/status.ts": 19, "lib/intl/__tests__/formatter-cache.test.ts": 1, - "lib/jobs/__tests__/apple-health-import-reconcile.test.ts": 2, - "lib/jobs/__tests__/apple-health-import-send-policy.test.ts": 1, - "lib/jobs/__tests__/coach-memory-refresh-queue.test.ts": 2, - "lib/jobs/__tests__/coach-message-cleanup-queue.test.ts": 1, + "lib/jobs/__tests__/apple-health-import-reconcile.test.ts": 1, "lib/jobs/__tests__/coach-nudge.test.ts": 3, "lib/jobs/__tests__/coach-reminder-sweep.test.ts": 1, - "lib/jobs/__tests__/cumulative-pr-rederive-queue.test.ts": 1, - "lib/jobs/__tests__/cycle-reminder-queue.test.ts": 2, "lib/jobs/__tests__/cycle-reminder.test.ts": 2, - "lib/jobs/__tests__/data-arrival-queue.test.ts": 1, - "lib/jobs/__tests__/drain-cumulative-queue.test.ts": 4, "lib/jobs/__tests__/feedback-aggregator.test.ts": 2, - "lib/jobs/__tests__/fitbit-backfill.test.ts": 1, - "lib/jobs/__tests__/fitbit-queues.test.ts": 2, - "lib/jobs/__tests__/geo-backfill.test.ts": 3, - "lib/jobs/__tests__/google-health-queues.test.ts": 1, + "lib/jobs/__tests__/geo-backfill.test.ts": 1, "lib/jobs/__tests__/google-health-sleep-repair.test.ts": 1, "lib/jobs/__tests__/hero-line-json-envelope.test.ts": 1, "lib/jobs/__tests__/insight-pregenerate-retry-enqueue.test.ts": 1, - "lib/jobs/__tests__/insight-pregenerate.test.ts": 16, - "lib/jobs/__tests__/insight-status-generate.test.ts": 2, + "lib/jobs/__tests__/insight-pregenerate.test.ts": 14, "lib/jobs/__tests__/intake-auto-skip.test.ts": 3, "lib/jobs/__tests__/intake-auto-take.test.ts": 1, - "lib/jobs/__tests__/intake-slot-dedup-queue.test.ts": 6, "lib/jobs/__tests__/job-run-report.test.ts": 1, - "lib/jobs/__tests__/mean-consolidation-queue.test.ts": 2, - "lib/jobs/__tests__/measurement-reminder-queue.test.ts": 2, "lib/jobs/__tests__/measurement-reminder.test.ts": 6, - "lib/jobs/__tests__/measurement-tombstone-cleanup.test.ts": 2, "lib/jobs/__tests__/medication-inventory-expire.test.ts": 1, "lib/jobs/__tests__/medication-low-stock.test.ts": 2, "lib/jobs/__tests__/medication-reminder-check.test.ts": 2, "lib/jobs/__tests__/mood-reminder.test.ts": 2, - "lib/jobs/__tests__/nightscout-queues.test.ts": 3, - "lib/jobs/__tests__/period-narrative-warm.test.ts": 1, "lib/jobs/__tests__/reaction-line-degradation.test.ts": 2, - "lib/jobs/__tests__/recovery-score-queue.test.ts": 2, "lib/jobs/__tests__/recovery-score.test.ts": 1, "lib/jobs/__tests__/reminder-phases.test.ts": 1, "lib/jobs/__tests__/reminder-satisfy.test.ts": 3, "lib/jobs/__tests__/report-worker-error.test.ts": 1, "lib/jobs/__tests__/run-job.test.ts": 1, "lib/jobs/__tests__/sleep-timeline-backfill.test.ts": 1, - "lib/jobs/__tests__/sleep-timeline-queues.test.ts": 2, - "lib/jobs/__tests__/status-cron-candidates.test.ts": 2, - "lib/jobs/__tests__/step-consolidation-repair-queue.test.ts": 1, - "lib/jobs/__tests__/stress-strain-retention-queue.test.ts": 2, - "lib/jobs/__tests__/tls-pin-monitor-queue.test.ts": 2, "lib/jobs/__tests__/whoop-backfill.test.ts": 1, - "lib/jobs/__tests__/whoop-queues.test.ts": 2, "lib/jobs/__tests__/whoop-resource-sync.test.ts": 1, - "lib/jobs/__tests__/withings-queues.test.ts": 2, - "lib/jobs/__tests__/workout-insight-queue.test.ts": 1, "lib/jobs/apple-health-import-worker.ts": 4, "lib/jobs/coach-message-cleanup.ts": 1, "lib/jobs/coach-nudge-ai.ts": 3, @@ -2359,8 +2336,6 @@ "lib/jobs/environment-air-quality-history.ts": 1, "lib/jobs/environment-fetch.ts": 2, "lib/jobs/feedback-aggregator.ts": 6, - "lib/jobs/fitbit-backfill.ts": 1, - "lib/jobs/fitbit-oauth-state-cleanup.ts": 1, "lib/jobs/free-text-encryption-backfill.ts": 8, "lib/jobs/geo-backfill.ts": 4, "lib/jobs/google-health-backfill.ts": 1, @@ -2393,11 +2368,10 @@ "lib/jobs/recovery-score.ts": 1, "lib/jobs/reminder-phases.ts": 1, "lib/jobs/reminder-satisfy.ts": 1, - "lib/jobs/reminder-worker.ts": 4, + "lib/jobs/reminder-worker.ts": 3, "lib/jobs/reminder/__tests__/cleanup-handlers-visibility.test.ts": 1, "lib/jobs/reminder/__tests__/queue-retention.test.ts": 1, "lib/jobs/reminder/cleanup-handlers.ts": 7, - "lib/jobs/reminder/fitbit-sync.ts": 2, "lib/jobs/reminder/google-health-sync.ts": 1, "lib/jobs/reminder/insights-handlers.ts": 3, "lib/jobs/reminder/medication-maintenance.ts": 3, @@ -2407,11 +2381,6 @@ "lib/jobs/reminder/oura-sync.ts": 1, "lib/jobs/reminder/polar-sync.ts": 2, "lib/jobs/reminder/poll-cohort.ts": 2, - "lib/jobs/reminder/register-integration-sync.ts": 63, - "lib/jobs/reminder/register-maintenance.ts": 114, - "lib/jobs/reminder/register-reminders.ts": 15, - "lib/jobs/reminder/register-rollup.ts": 37, - "lib/jobs/reminder/register-status.ts": 38, "lib/jobs/reminder/registrar-shared.ts": 2, "lib/jobs/reminder/strava-sync.ts": 1, "lib/jobs/reminder/whoop-sync.ts": 3, @@ -2712,7 +2681,6 @@ "lib/openapi/routes/workouts.ts": 4, "lib/optimistic-lock.ts": 1, "lib/oura/__tests__/sync.test.ts": 1, - "lib/oura/client.ts": 1, "lib/oura/credentials.ts": 3, "lib/oura/cycle-sync.ts": 1, "lib/oura/response-classifier.ts": 1, @@ -2724,7 +2692,6 @@ "lib/personal-records/pr-detection-worker.ts": 6, "lib/personal-records/pr-direction.ts": 26, "lib/polar/__tests__/dedup.test.ts": 1, - "lib/polar/client.ts": 1, "lib/polar/credentials.ts": 3, "lib/polar/response-classifier.ts": 1, "lib/polar/sync-workouts.ts": 1, @@ -2826,7 +2793,6 @@ "lib/sse/__tests__/create-stream.test.ts": 1, "lib/sse/create-stream.ts": 3, "lib/strava/__tests__/dedup.test.ts": 1, - "lib/strava/client.ts": 1, "lib/strava/credentials.ts": 1, "lib/strava/response-classifier.ts": 1, "lib/strava/sync.ts": 1, @@ -2928,7 +2894,6 @@ "lib/whoop/__tests__/sync-workout-overlap.test.ts": 1, "lib/whoop/__tests__/sync.test.ts": 1, "lib/whoop/__tests__/webhook-notification.test.ts": 1, - "lib/whoop/client.ts": 3, "lib/whoop/connect-ticket.ts": 1, "lib/whoop/oauth-state.ts": 1, "lib/whoop/return-scheme.ts": 1, @@ -2941,13 +2906,13 @@ "lib/withings/__tests__/sync-sleep.test.ts": 4, "lib/withings/__tests__/sync-typed-classification.test.ts": 1, "lib/withings/__tests__/sync.test.ts": 1, - "lib/withings/client.ts": 5, + "lib/withings/client.ts": 3, "lib/withings/oauth-state.ts": 1, "lib/withings/response-classifier.ts": 1, "lib/withings/sync-activity.ts": 7, "lib/withings/sync-ecg.ts": 4, "lib/withings/sync-sleep.ts": 9, - "lib/withings/sync.ts": 9, + "lib/withings/sync.ts": 6, "lib/withings/webhook-handler.ts": 3, "lib/workouts/list-read.ts": 1, "proxy.ts": 29, diff --git a/src/__tests__/env-empty-is-unset-guard.test.ts b/src/__tests__/env-empty-is-unset-guard.test.ts index a50b87bbc..952131da6 100644 --- a/src/__tests__/env-empty-is-unset-guard.test.ts +++ b/src/__tests__/env-empty-is-unset-guard.test.ts @@ -88,15 +88,18 @@ describe("an empty environment variable counts as unset", () => { it("T3 — the sweep read the tree and the matcher fires on real source", () => { // An empty offender list proves nothing if the narrowed walk found no // files, or if the matcher no longer fires on what the tree actually - // holds. Floor the narrowed set, then put the pre-fix read back into a - // real client and require the matcher to find it there. + // holds. Floor the narrowed set, then put the pre-fix read back into the + // redirect-URI helper every client uses and require the matcher to find it there. const files = sourceFiles(); expect(files.length).toBeGreaterThan(2400); - expect(files).toContain("lib/withings/client.ts"); - const real = readFileSync(join(SRC, "lib/withings/client.ts"), "utf8"); + expect(files).toContain("lib/integrations/oauth-kit/redirect-uri.ts"); + const real = readFileSync( + join(SRC, "lib/integrations/oauth-kit/redirect-uri.ts"), + "utf8", + ); expect([...real.matchAll(NULLISH_ENV_FALLBACK)]).toEqual([]); const reverted = real.replace( - /envOr\(\s*"WITHINGS_REDIRECT_URI",/, + /envOr\(envVar,/, "process.env.WITHINGS_REDIRECT_URI ?? (", ); expect(reverted).not.toBe(real); diff --git a/src/__tests__/guard-floor-guard.test.ts b/src/__tests__/guard-floor-guard.test.ts index 7578cf6ce..687ec7d31 100644 --- a/src/__tests__/guard-floor-guard.test.ts +++ b/src/__tests__/guard-floor-guard.test.ts @@ -63,8 +63,6 @@ const NO_FLOOR_NEEDED: Readonly> = { "named walks, each must positively match the keyset cursor", "components/charts/__tests__/touch-action-guard.test.ts": "named chart wrappers, each must positively declare touch-pan-y", - "__tests__/moodlog-removal-guard.test.ts": - "absence checks over named paths, columns and keys; no matcher to drift", // Unit tests of runtime code whose name ends in "guard". They read no // source and sweep nothing. diff --git a/src/__tests__/helpers/source-metrics.ts b/src/__tests__/helpers/source-metrics.ts index f03d8b365..5fa9d3a2d 100644 --- a/src/__tests__/helpers/source-metrics.ts +++ b/src/__tests__/helpers/source-metrics.ts @@ -197,6 +197,8 @@ export interface ModuleImports { value: string[]; /** True when every statement is an `export … from` (or an import). */ reexportOnly: boolean; + /** The value imports evaluated when the module loads: `value` without `import("…")`. */ + staticValue: string[]; } /** @@ -240,6 +242,7 @@ export function moduleImports(sf: ts.SourceFile): ModuleImports { } other += 1; } + const staticValue = [...value]; const visit = (node: ts.Node): void => { if ( ts.isCallExpression(node) && @@ -252,7 +255,7 @@ export function moduleImports(sf: ts.SourceFile): ModuleImports { ts.forEachChild(node, visit); }; visit(sf); - return { value, reexportOnly: reexports > 0 && other === 0 }; + return { value, staticValue, reexportOnly: reexports > 0 && other === 0 }; } export interface ImportGraph { @@ -374,3 +377,40 @@ export function backEdgeCycles(edges: Map): string[][] { for (const v of [...edges.keys()].sort()) walk(v); return cycles; } + +/** + * Every module under `src/` that loading `entries` evaluates: static value + * imports and re-exports, transitively. A dynamic `import("…")` is not + * followed, since it loads only when it runs. Paths are relative to `src/`. + */ +export function staticImportClosure(entries: readonly string[]): Set { + const cache = ts.createModuleResolutionCache( + process.cwd(), + (x) => x, + COMPILER_OPTIONS, + ); + const seen = new Set(); + const stack = [...entries]; + while (stack.length > 0) { + const rel = stack.pop()!; + if (seen.has(rel)) continue; + seen.add(rel); + const abs = join(SRC, rel); + for (const spec of moduleImports(parse(rel, readFileSync(abs, "utf8"))) + .staticValue) { + const hit = ts.resolveModuleName( + spec, + abs, + COMPILER_OPTIONS, + ts.sys, + cache, + ).resolvedModule; + if (!hit || hit.isExternalLibraryImport) continue; + if (!hit.resolvedFileName.startsWith(`${SRC}/`)) continue; + const target = hit.resolvedFileName.slice(SRC.length + 1); + if (target.startsWith("generated/") || target.endsWith(".d.ts")) continue; + stack.push(target); + } + } + return seen; +} diff --git a/src/__tests__/job-queue-runtime-guard.test.ts b/src/__tests__/job-queue-runtime-guard.test.ts index 48da4c47d..90b14d8ec 100644 --- a/src/__tests__/job-queue-runtime-guard.test.ts +++ b/src/__tests__/job-queue-runtime-guard.test.ts @@ -11,23 +11,23 @@ * `src/lib/jobs/queue-runtime.ts` declares every queue `short`, `budgeted` or * `long`. This guard holds the tree to it: * - * - every queue bound with `createAndWork` is in the table, and every table - * entry is bound; - * - every `boss.send` and every schedule tuple names a queue in the table; + * - every queue the worker registry declares (`jobs/registry/`) is in the + * table, and every table entry is declared; + * - every `boss.send` and every scheduled queue names a queue in the table; * - a `long` queue has an expiry between the default and pg-boss's 24-hour - * ceiling, every send and schedule for it carries `expireInSeconds` (or an - * identifier the table names as its carrier), it stops on the job's budget - * where the table says (`jobBudget` / `jobDeadline`, or a declared - * one-shot), and it is exclusive per identity where the table says - * (`lockedPass` at the binding, or the named marker); + * ceiling, every send for it carries `expireInSeconds` (or an identifier + * the table names as its carrier) and its schedule's send options do, it + * stops on the job's budget where the table says (`jobBudget` / + * `jobDeadline`, or a declared one-shot), and it is exclusive per identity + * where the table says (the definition's `lock`, or the named marker); * - a `budgeted` queue stops on the budget and relies on the default expiry; * - a `short` queue's sends set no expiry of their own, which would mean the * job is longer than the table claims. * * Floors fail the guard if a matcher stops matching, and the "plants" cases * run the checks over a planted long queue that lacks each protection in turn - * (the mutation proof kept in the suite). Removing `lockedPass` from the - * pr-detection binding, or the expiry from its cron tuple, fails the real + * (the mutation proof kept in the suite). Removing the `lock` from the + * pr-detection definition, or the expiry from its schedule, fails the real * checks below. */ import { describe, expect, it } from "vitest"; @@ -39,19 +39,14 @@ import { DEFAULT_JOB_EXPIRE_SECONDS, QUEUE_RUNTIME, type QueueRuntime, - type SourceSite, } from "@/lib/jobs/queue-runtime"; import { SCHEDULED_AND_SENT_QUEUES } from "@/lib/jobs/reminder/registrar-shared"; import { HEAVY_QUEUES } from "@/lib/jobs/heavy-lane"; +import type { JobDefinition } from "@/lib/jobs/registry/define-job"; +import { allJobDefinitions } from "@/lib/jobs/registry/index"; const SRC = join(process.cwd(), "src"); -const REGISTRARS = [ - "lib/jobs/reminder/register-integration-sync.ts", - "lib/jobs/reminder/register-maintenance.ts", - "lib/jobs/reminder/register-reminders.ts", - "lib/jobs/reminder/register-rollup.ts", - "lib/jobs/reminder/register-status.ts", -]; +const DEFINITIONS = allJobDefinitions(); /** pg-boss refuses an expiry of 24 hours or more. */ const MAX_EXPIRE_SECONDS = 24 * 60 * 60; @@ -120,27 +115,6 @@ interface Site { text: string; } -/** Every `createAndWork(boss, QUEUE, …)` in the registrars. */ -function bindings( - texts: ReadonlyMap, - definitions: Definitions, -): Site[] { - const out: Site[] = []; - for (const file of REGISTRARS) { - const text = code(texts.get(file) ?? ""); - for (const m of text.matchAll( - /createAndWork\s*(?:<[^(]*?>)?\(\s*boss\s*,\s*([A-Z][A-Z0-9_]*)/g, - )) { - const open = text.indexOf("(", m.index); - const queue = resolve(definitions, file, m[1]); - if (queue) { - out.push({ file, queue, text: text.slice(open, closeOf(text, open)) }); - } - } - } - return out; -} - /** Every `boss.send(QUEUE, …)` / `getGlobalBoss()?.send(QUEUE, …)`. */ function sends( texts: ReadonlyMap, @@ -162,31 +136,6 @@ function sends( return out; } -/** Every `[QUEUE, CRON, options?]` tuple in a registrar's `schedules`. */ -function schedules( - texts: ReadonlyMap, - definitions: Definitions, -): Site[] { - const out: Site[] = []; - for (const file of REGISTRARS) { - const text = code(texts.get(file) ?? ""); - const m = /const\s+schedules\s*:\s*ScheduleEntry\[\]\s*=\s*\[/.exec(text); - if (!m) continue; - const open = m.index + m[0].length - 1; - const body = text.slice(open + 1, closeOf(text, open) - 1); - for (let i = 0; i < body.length; i += 1) { - if (body[i] !== "[") continue; - const end = closeOf(body, i); - const tuple = body.slice(i, end); - const ident = /^\[\s*([A-Z][A-Z0-9_]*)/.exec(tuple)?.[1]; - const queue = ident ? resolve(definitions, file, ident) : null; - if (queue) out.push({ file, queue, text: tuple }); - i = end - 1; - } - } - return out; -} - /** The text of `fn` (a function or a const) in `file`. */ function bodyOf( texts: ReadonlyMap, @@ -201,14 +150,6 @@ function bodyOf( return text.slice(m.index, end < 0 ? undefined : end + 2); } -function siteText( - texts: ReadonlyMap, - site: SourceSite, - binding: Site | undefined, -): string { - return site === "binding" ? (binding?.text ?? "") : bodyOf(texts, site); -} - const STOPS = /\bjob(?:Budget|Deadline)\s*\(/; /** Every finding for one queue's declaration against the tree. */ @@ -216,13 +157,12 @@ function check( queue: string, entry: QueueRuntime, texts: ReadonlyMap, - all: { bindings: Site[]; sends: Site[]; schedules: Site[] }, + all: { definitions: readonly JobDefinition[]; sends: Site[] }, ): string[] { const problems: string[] = []; - const binding = all.bindings.find((b) => b.queue === queue); - const carriers = [...all.sends, ...all.schedules].filter( - (s) => s.queue === queue, - ); + const definition = all.definitions.find((d) => d.queue === queue); + const sends = all.sends.filter((s) => s.queue === queue); + const scheduledExpiry = definition?.scheduleOptions?.expireInSeconds; if (entry.runtime === "long") { if ( !(entry.expireInSeconds > DEFAULT_JOB_EXPIRE_SECONDS) || @@ -230,85 +170,86 @@ function check( ) { problems.push(`${queue}: expiry ${entry.expireInSeconds}s out of range`); } - for (const site of carriers) { + for (const site of sends) { const carried = /\bexpireInSeconds\b/.test(site.text) || entry.expiryVia.some((v) => new RegExp(`\\b${v}\\b`).test(site.text)); if (!carried) { - problems.push( - `${queue}: ${site.file} sends or schedules it without an expiry`, - ); + problems.push(`${queue}: ${site.file} sends it without an expiry`); } } + if (definition?.cron && typeof scheduledExpiry !== "number") { + problems.push(`${queue}: its schedule carries no expiry`); + } if (typeof entry.stop === "object" && "oneShot" in entry.stop) { if (entry.stop.oneShot.length < 20) { problems.push(`${queue}: a one-shot needs its reason`); } - } else if (!STOPS.test(siteText(texts, entry.stop, binding))) { + } else if (!STOPS.test(bodyOf(texts, entry.stop))) { problems.push(`${queue}: does not stop on the job's budget`); } if (entry.exclusive === "lockedPass") { - if (!/\blockedPass\s*\(/.test(binding?.text ?? "")) { - problems.push(`${queue}: binding holds no lockedPass`); + if (!definition?.lock) { + problems.push(`${queue}: definition holds no lock`); } } else if ( - !siteText(texts, entry.exclusive.at, binding).includes( - entry.exclusive.marker, - ) + !bodyOf(texts, entry.exclusive.at).includes(entry.exclusive.marker) ) { problems.push(`${queue}: no "${entry.exclusive.marker}" where declared`); } } else { if (entry.runtime === "budgeted") { - if (!STOPS.test(siteText(texts, entry.budget, binding))) { + if (!STOPS.test(bodyOf(texts, entry.budget))) { problems.push(`${queue}: does not stop on the job's budget`); } } - for (const site of carriers) { + for (const site of sends) { if (/\bexpireInSeconds\b/.test(site.text)) { problems.push( `${queue}: ${site.file} sets an expiry on a queue declared ${entry.runtime}`, ); } } + if (scheduledExpiry !== undefined) { + problems.push( + `${queue}: its schedule sets an expiry on a queue declared ${entry.runtime}`, + ); + } } return problems; } function scan(texts: ReadonlyMap) { - const definitions = definitionsOf(texts); - return { - bindings: bindings(texts, definitions), - sends: sends(texts, definitions), - schedules: schedules(texts, definitions), - }; + return { sends: sends(texts, definitionsOf(texts)) }; } describe("every queue declares its runtime", () => { - const all = scan(source); - const bound = new Set(all.bindings.map((b) => b.queue)); + const all = { definitions: DEFINITIONS, ...scan(source) }; + const declared = new Set(DEFINITIONS.map((d) => d.queue)); - it("finds the bindings, sends and schedules it is meant to police", () => { - // Pinned below the counts on 2026-10-01 (112 / 64 / 74). - expect(all.bindings.length).toBeGreaterThanOrEqual(105); + it("finds the declarations, sends and schedules it is meant to police", () => { + // Pinned below the counts on 2026-10-23 (122 / 64 / 78). + expect(DEFINITIONS.length).toBeGreaterThanOrEqual(115); expect(all.sends.length).toBeGreaterThanOrEqual(50); - expect(all.schedules.length).toBeGreaterThanOrEqual(70); + expect(DEFINITIONS.filter((d) => d.cron).length).toBeGreaterThanOrEqual(70); expect( Object.values(QUEUE_RUNTIME).filter((e) => e.runtime === "long").length, ).toBeGreaterThanOrEqual(15); }); - it("every bound queue is in the table, and every entry is bound", () => { - expect([...bound].filter((q) => !(q in QUEUE_RUNTIME)).sort()).toEqual([]); + it("every declared queue is in the table, and every entry is declared", () => { + expect([...declared].filter((q) => !(q in QUEUE_RUNTIME)).sort()).toEqual( + [], + ); expect( Object.keys(QUEUE_RUNTIME) - .filter((q) => !bound.has(q)) + .filter((q) => !declared.has(q)) .sort(), ).toEqual([]); }); - it("every send and schedule names a queue in the table", () => { - const unknown = [...all.sends, ...all.schedules] + it("every send names a queue in the table", () => { + const unknown = all.sends .filter((s) => !(s.queue in QUEUE_RUNTIME)) .map((s) => `${s.file} → ${s.queue}`); expect(unknown).toEqual([]); @@ -322,51 +263,56 @@ describe("every queue declares its runtime", () => { }); it("plants: a long queue missing any protection is caught", () => { - const registrar = REGISTRARS[1]; - const planted = (binding: string, schedule: string) => + const file = "lib/jobs/planted.ts"; + const texts = (handler: string) => new Map([ ...source, - [ - registrar, - `const PLANT_QUEUE = "planted-long";\n` + - `const schedules: ScheduleEntry[] = [\n ${schedule},\n];\n` + - `async function register(boss) {\n await createAndWork(boss, PLANT_QUEUE, { localConcurrency: 1 }, ${binding});\n}\n`, - ], + [file, `export async function plant(jobs) {\n ${handler}\n}\n`], ]); const entry: QueueRuntime = { runtime: "long", expireInSeconds: 3600, expiryVia: [], - stop: "binding", + stop: { file, fn: "plant" }, exclusive: "lockedPass", why: "planted", }; - const findings = (binding: string, schedule: string) => { - const texts = planted(binding, schedule); - return check("planted-long", entry, texts, scan(texts)); + const planted = (overrides: Partial): JobDefinition => ({ + queue: "planted-long", + cron: "0 3 * * *", + scheduleOptions: { expireInSeconds: 3600 }, + work: { localConcurrency: 1 }, + lock: () => "pass", + load: async () => async () => ({ ok: true, did: {} }), + ...overrides, + }); + const findings = ( + handler: string, + overrides: Partial = {}, + ) => { + const t = texts(handler); + return check("planted-long", entry, t, { + definitions: [planted(overrides)], + ...scan(t), + }); }; - const safe = [ - "lockedPass(PLANT_QUEUE, () => WHOLE_PASS, async (jobs) => run(jobBudget(jobs)))", - "[PLANT_QUEUE, CRON, { expireInSeconds: 3600 }]", - ] as const; - expect(findings(...safe)).toEqual([]); - expect(findings(safe[0], "[PLANT_QUEUE, CRON]")).toEqual([ - `planted-long: ${registrar} sends or schedules it without an expiry`, + const budgeted = "return run(jobBudget(jobs));"; + expect(findings(budgeted)).toEqual([]); + expect(findings(budgeted, { scheduleOptions: { retryLimit: 0 } })).toEqual([ + "planted-long: its schedule carries no expiry", ]); - expect( - findings("lockedPass(PLANT_QUEUE, () => WHOLE_PASS, run)", safe[1]), - ).toEqual(["planted-long: does not stop on the job's budget"]); - expect(findings("async (jobs) => run(jobBudget(jobs))", safe[1])).toEqual([ - "planted-long: binding holds no lockedPass", + expect(findings("return run(jobs);")).toEqual([ + "planted-long: does not stop on the job's budget", + ]); + expect(findings(budgeted, { lock: undefined })).toEqual([ + "planted-long: definition holds no lock", ]); expect( check( "planted-long", { ...entry, expireInSeconds: 900 }, - ...((): [ReadonlyMap, ReturnType] => { - const texts = planted(...safe); - return [texts, scan(texts)]; - })(), + texts(budgeted), + { definitions: [planted({})], ...scan(texts(budgeted)) }, ), ).toEqual(["planted-long: expiry 900s out of range"]); }); @@ -374,12 +320,13 @@ describe("every queue declares its runtime", () => { /** * Queues a file sends to through a variable (`boss.send(queueName, …)`): every - * known queue name the file spells out, as a constant or a map value, counts - * as sent, because the matcher above cannot tell which one the variable holds. + * known queue the file names, as a literal, a map value or an imported + * `*_QUEUE` constant, counts as sent, because the matcher above cannot tell which one the variable holds. */ function dynamicallySent(texts: ReadonlyMap): Set { + const definitions = definitionsOf(texts); const out = new Set(); - for (const [, raw] of texts) { + for (const [file, raw] of texts) { const text = code(raw); if ( !/\b(?:boss|getGlobalBoss\(\)\??)\s*\.\s*send\s*(?:<[^(]*?>)?\(\s*[a-z]/.test( @@ -391,13 +338,20 @@ function dynamicallySent(texts: ReadonlyMap): Set { for (const m of text.matchAll(/"([a-z0-9-]+)"/g)) { if (m[1] in QUEUE_RUNTIME) out.add(m[1]); } + // A queue named through an imported constant counts the same. + for (const m of text.matchAll(/\b([A-Z][A-Z0-9_]*_QUEUE)\b/g)) { + const queue = resolve(definitions, file, m[1]); + if (queue && queue in QUEUE_RUNTIME) out.add(queue); + } } return out; } describe("a cron-fed queue polls slowly, a sent one does not", () => { const all = scan(source); - const scheduled = new Set(all.schedules.map((s) => s.queue)); + const scheduled = new Set( + DEFINITIONS.filter((d) => d.cron).map((d) => d.queue), + ); const sent = new Set([ ...all.sends.map((s) => s.queue), ...dynamicallySent(source), @@ -418,26 +372,14 @@ describe("a cron-fed queue polls slowly, a sent one does not", () => { }); describe("heavy jobs share the lane", () => { - const all = scan(source); - const laned = all.bindings.filter((b) => /\bheavyLane\s*\(/.test(b.text)); + const laned = DEFINITIONS.filter((d) => d.lane); - it("binds every heavy queue in the lane, and nothing else", () => { - expect(laned.map((b) => b.queue).sort()).toEqual( + it("puts every heavy queue in the lane, and nothing else", () => { + expect(laned.map((d) => d.queue).sort()).toEqual( Object.keys(HEAVY_QUEUES).sort(), ); }); - it("holds a lockedPass outside the lane, so an expiry retry finds the run going", () => { - // Outside, a retry pg-boss starts while the expired run still holds the - // lane finds the lock and settles; inside, the lane would defer it and - // run the job a second time once the first is done. - const inverted = laned - .filter((b) => /\blockedPass\s*\(/.test(b.text)) - .filter((b) => b.text.indexOf("lockedPass") > b.text.indexOf("heavyLane")) - .map((b) => b.queue); - expect(inverted).toEqual([]); - }); - it("declares every heavy queue long, so its expiry, stop and exclusivity are proven", () => { // A heavy job is the one most likely to run past pg-boss's default // expiry, and the retry that follows an expiry runs it a second time. @@ -464,41 +406,28 @@ describe("heavy jobs share the lane", () => { // A deferred job returns from active to created. Under `short` (one // queued job per key) and `stately` (one per state) a twin already // queued would refuse it, and the job would fail instead of waiting. - const policies = policiesOf(source); - expect(policies.size).toBeGreaterThanOrEqual(40); - const refused = Object.keys(HEAVY_QUEUES) - .filter((queue) => /^(?:short|stately)$/.test(policies.get(queue) ?? "")) - .sort(); - expect(refused).toEqual([]); - // The planted case: the same check over a table that declares one. - const planted = new Map(source); - const file = REGISTRARS[2]; - planted.set( - file, - `const DATA_BACKUP_QUEUE = "data-backup";\nconst queuePolicies: QueuePolicyTable = {\n [DATA_BACKUP_QUEUE]: { policy: "short", reason: "x" },\n};\n`, + const refusing = (definitions: readonly JobDefinition[]) => + definitions + .filter( + (d) => d.lane && /^(?:short|stately)$/.test(d.policy?.policy ?? ""), + ) + .map((d) => d.queue) + .sort(); + expect(DEFINITIONS.filter((d) => d.policy).length).toBeGreaterThanOrEqual( + 40, ); - expect(policiesOf(planted).get("data-backup")).toBe("short"); + expect(refusing(DEFINITIONS)).toEqual([]); + // The planted case: the same check over a heavy queue declared `short`. + expect( + refusing([ + { + queue: "data-backup", + policy: { policy: "short", reason: "x" }, + work: { localConcurrency: 1 }, + lane: true, + load: async () => async () => ({ ok: true, did: {} }), + }, + ]), + ).toEqual(["data-backup"]); }); }); - -/** Every `[QUEUE]: { policy: "…" }` in a registrar's `queuePolicies`. */ -function policiesOf(texts: ReadonlyMap): Map { - const definitions = definitionsOf(texts); - const out = new Map(); - for (const file of REGISTRARS) { - const text = code(texts.get(file) ?? ""); - const m = /const\s+queuePolicies\s*:\s*QueuePolicyTable\s*=\s*\{/.exec( - text, - ); - if (!m) continue; - const open = m.index + m[0].length - 1; - const body = text.slice(open, closeOf(text, open)); - for (const entry of body.matchAll( - /\[\s*([A-Z][A-Z0-9_]*)\s*\]\s*:\s*\{\s*policy\s*:\s*"([a-z]+)"/g, - )) { - const queue = resolve(definitions, file, entry[1]); - if (queue) out.set(queue, entry[2]); - } - } - return out; -} diff --git a/src/__tests__/measurement-write-tail-guard.test.ts b/src/__tests__/measurement-write-tail-guard.test.ts index a9befc1ff..9a7f671a4 100644 --- a/src/__tests__/measurement-write-tail-guard.test.ts +++ b/src/__tests__/measurement-write-tail-guard.test.ts @@ -39,6 +39,13 @@ const TAIL = "runs afterMeasurementMutation"; const TAIL_VIA_PREFIX = "runs afterMeasurementMutation via "; const TAIL_VIA = (file: string) => `${TAIL_VIA_PREFIX}${file}`; +/** + * Runs the provider write step (`integrations/sync-kit/after-write.ts`), + * which is `afterMeasurementMutation` plus the arrivals and, where asked, the + * dashboard snapshot. + */ +const PROVIDER_TAIL = "runs afterProviderWrite"; + /** * Every file with a measurement write, and how it keeps the two readers * current. A file listed here must still contain a write (a stale entry @@ -66,17 +73,13 @@ const WRITE_SITES: Record = { // Retiring the equivalent-distance rows earlier Oura syncs wrote. "lib/oura/retire-distance.ts": TAIL, // Integration syncs: each collects the (type, day) pairs it touched and - // runs both legs itself at the end of the sync. - "lib/fitbit/sync-core.ts": - "refolds the touched days and re-warms the touched types at the end of the upsert", - "lib/google-health/sync-core.ts": - "refolds the touched days and re-warms the touched types at the end of the upsert", - "lib/nightscout/sync.ts": - "refolds the touched days and re-warms the touched types at the end of the sync", + // runs the provider write step over them. + "lib/fitbit/sync-core.ts": PROVIDER_TAIL, + "lib/google-health/sync-core.ts": PROVIDER_TAIL, + "lib/nightscout/sync.ts": PROVIDER_TAIL, + "lib/withings/sync.ts": PROVIDER_TAIL, "lib/oura/sync-heart-rate.ts": "collects the days each heart-rate page touched; refolds them and re-warms the type once per window", - "lib/withings/sync.ts": - "refolds the touched days and re-warms the touched types at the end of the sync", "lib/withings/sync-activity.ts": "refolds the touched days (and the day a moved row left) and re-warms the touched types", "lib/withings/sync-ecg.ts": @@ -165,10 +168,15 @@ describe("measurement write tail", () => { }); it("the files that claim the shared tail call it", () => { - const missing = Object.entries(WRITE_SITES) - .filter( - ([, reason]) => reason === TAIL || reason.startsWith(TAIL_VIA_PREFIX), - ) + const claims = Object.entries(WRITE_SITES).filter( + ([, reason]) => reason === TAIL || reason.startsWith(TAIL_VIA_PREFIX), + ); + // Both shapes are in use; a set that lost either would check nothing. + expect(claims.filter(([, r]) => r === TAIL).length).toBeGreaterThan(5); + expect( + claims.filter(([, r]) => r.startsWith(TAIL_VIA_PREFIX)).length, + ).toBeGreaterThan(0); + const missing = claims .map(([file, reason]) => reason.startsWith(TAIL_VIA_PREFIX) ? reason.slice(TAIL_VIA_PREFIX.length) @@ -183,6 +191,18 @@ describe("measurement write tail", () => { expect(missing).toEqual([]); }); + it("the files that claim the provider write step call it", () => { + const claimed = Object.entries(WRITE_SITES) + .filter(([, reason]) => reason === PROVIDER_TAIL) + .map(([file]) => file); + expect(claimed.length).toBeGreaterThan(0); + const missing = claimed.filter( + (file) => + !/afterProviderWrite\s*\(/.test(readFileSync(join(SRC, file), "utf8")), + ); + expect(missing).toEqual([]); + }); + it("the syncs that run both legs by hand call both", () => { const handRolled = Object.entries(WRITE_SITES) .filter(([, reason]) => reason.includes("re-warms")) diff --git a/src/__tests__/moodlog-removal-guard.test.ts b/src/__tests__/moodlog-removal-guard.test.ts index b2b218b14..c012aed0f 100644 --- a/src/__tests__/moodlog-removal-guard.test.ts +++ b/src/__tests__/moodlog-removal-guard.test.ts @@ -18,11 +18,12 @@ */ import { readFileSync, existsSync } from "node:fs"; import path from "node:path"; + +import { allJobDefinitions } from "@/lib/jobs/registry/index"; import { describe, expect, it } from "vitest"; import { INTEGRATION_CADENCE } from "@/lib/integrations/sync-verdict"; import { INTEGRATION_DISPLAY_NAMES } from "@/components/settings/integrations/integration-fallback-row"; -import { readSource } from "@/__tests__/helpers/source-files"; const ROOT = path.resolve(__dirname, "../.."); @@ -76,12 +77,9 @@ describe("moodLog integration stays removed", () => { }); it("registers no moodlog queue, cron or handler binding", () => { - const registrar = readSource( - "src/lib/jobs/reminder/register-integration-sync.ts", - "export async function registerIntegrationSyncQueues", - ); - expect(registrar).not.toContain("moodlog"); - expect(registrar).not.toContain("MoodLog"); + const queues = allJobDefinitions().map((job) => job.queue); + expect(queues.length).toBeGreaterThan(100); + expect(queues.filter((queue) => /mood-?log/i.test(queue))).toEqual([]); }); it("carries no moodlog integration key on the status envelope", () => { diff --git a/src/__tests__/oauth-kit-guard.test.ts b/src/__tests__/oauth-kit-guard.test.ts index 4021ebb08..4d56ef7bd 100644 --- a/src/__tests__/oauth-kit-guard.test.ts +++ b/src/__tests__/oauth-kit-guard.test.ts @@ -10,17 +10,23 @@ * * A. Each kit provider has `src/lib//oauth-descriptor.ts`, and the * descriptor names that provider. - * B. A provider client's `refreshAccessToken` is imported by that - * provider's descriptor and by nothing else outside tests. + * B. A provider's refresh (`OAuth.refresh(…)`, from the shared + * OAuth client factory) is called by that provider's descriptor and by + * nothing else outside tests. * C. The refresh lock is taken by the kit and by the AI provider store * (`src/lib/ai/provider.ts`, which is not an integration) only. * D. A token is sealed into a connection row only by a callback route * (the connect-time write). The kit writes ciphertext the descriptor is * handed and never encrypts on its own. + * E. A provider integration speaks to a token endpoint only through the + * factory (`oauth-kit/client.ts`): no other integration module builds a + * `grant_type` form. Sign-in (OIDC), the MCP authorization server and + * the Codex device login are not provider integrations. * - * Fitbit is the one listed exception: the classic Fitbit Web API is retired - * on 2026-10-30 and its integration rides out on its own refresh until then, - * rather than being migrated onto a lifecycle it will never use. + * Fitbit is the one listed exception to the lifecycle: the classic Fitbit Web + * API is retired on 2026-10-30 and its integration rides out on its own + * refresh until then, rather than being migrated onto a lifecycle it will + * never use. Its wire goes through the factory like every other provider's. * * Every leg asserts a non-empty match set before it compares, so a matcher * that stops matching fails instead of passing. @@ -68,21 +74,18 @@ describe("OAuth token lifecycle guard", () => { } }); - it("B: a provider's refreshAccessToken is imported only by its descriptor", () => { - const importers = files.filter((f) => { - const src = read(f); - return /import\s*\{[^}]*\brefreshAccessToken\b[^}]*\}\s*from\s*["'](?:\.\/client|@\/lib\/[a-z-]+\/client)["']/.test( - src, - ); - }); - expect(importers.length).toBeGreaterThan(0); + it("B: a provider's refresh is called only by its descriptor", () => { + const callers = files.filter((f) => + /\b[a-z][A-Za-z]*OAuth\.refresh\s*\(/.test(read(f)), + ); + expect(callers.length).toBeGreaterThan(0); const allowed = new Set([ ...KIT_PROVIDERS.filter((p) => p !== "polar").map( (p) => `src/lib/${p}/oauth-descriptor.ts`, ), ...RETIRED, ]); - expect(importers.filter((f) => !allowed.has(f))).toEqual([]); + expect(callers.filter((f) => !allowed.has(f))).toEqual([]); }); it("C: only the kit and the AI provider store take the refresh lock", () => { @@ -111,4 +114,18 @@ describe("OAuth token lifecycle guard", () => { ); expect(offenders).toEqual([]); }); + + it("E: provider integrations reach a token endpoint only through the factory", () => { + const forms = files.filter((f) => /\bgrant_type\b/.test(read(f))); + expect(forms.length).toBeGreaterThan(0); + const NOT_INTEGRATIONS = new Set([ + "src/lib/integrations/oauth-kit/client.ts", + "src/lib/auth/oidc.ts", + "src/lib/ai/codex-oauth.ts", + "src/lib/mcp/oauth/metadata.ts", + "src/app/api/mcp/oauth/token/route.ts", + "src/app/api/mcp/oauth/register/route.ts", + ]); + expect(forms.filter((f) => !NOT_INTEGRATIONS.has(f)).sort()).toEqual([]); + }); }); diff --git a/src/__tests__/page-walk-guard.test.ts b/src/__tests__/page-walk-guard.test.ts index a08129755..29133cff6 100644 --- a/src/__tests__/page-walk-guard.test.ts +++ b/src/__tests__/page-walk-guard.test.ts @@ -35,7 +35,7 @@ const LIB = join(process.cwd(), "src", "lib"); * three outcomes cannot carry. */ const EXCEPTIONS: Readonly> = { - "google-health/client.ts": { + "google-health/data-points.ts": { matches: 1, why: "progress-bounded streaming list walk with slice budget and resume (forEachDataPointPage)", }, diff --git a/src/__tests__/provider-ai-boundary-guard.test.ts b/src/__tests__/provider-ai-boundary-guard.test.ts new file mode 100644 index 000000000..01b8c2629 --- /dev/null +++ b/src/__tests__/provider-ai-boundary-guard.test.ts @@ -0,0 +1,70 @@ +/** + * A provider sync loads none of the AI layer. + * + * Thirteen sync modules once imported `insights/comprehensive-generate` to + * reach one invalidation function it re-exported, so every sync worker + * evaluated the whole insight generator. The write tail is + * `integrations/sync-kit/after-write.ts` now, and it reaches the status cards + * through `insights/status-invalidation`. This guard holds that: + * + * - no production module in a provider folder (`PROVIDER_DIRS`) imports + * anything under `lib/ai/` or the comprehensive generator; + * - and loading any of them does not evaluate the comprehensive generator + * transitively either. + * + * The planted case runs the transitive check over a module that does load + * it, so a walk that resolves nothing cannot pass. + */ +import { describe, expect, it } from "vitest"; + +import { PROVIDER_DIRS } from "./helpers/provider-dirs"; +import { requireFloor, walkSourceFiles } from "./helpers/source-files"; +import { + SRC, + moduleImports, + parse, + readSrc, + staticImportClosure, +} from "./helpers/source-metrics"; + +const GENERATOR = "lib/insights/comprehensive-generate.ts"; +const FORBIDDEN_SPEC = /^@\/lib\/(?:ai\/|insights\/comprehensive-generate$)/; + +function providerModules(): string[] { + return PROVIDER_DIRS.flatMap((dir) => + walkSourceFiles(`${SRC}/lib/${dir}`, { floor: 1 }) + .filter((f) => !f.includes("__tests__") && !/\.test\.tsx?$/.test(f)) + .map((f) => `lib/${dir}/${f}`), + ); +} + +describe("provider folders and the AI layer", () => { + const modules = providerModules(); + + it("reads every provider folder", () => { + requireFloor("provider modules", modules, 60); + }); + + it("imports nothing from lib/ai or the comprehensive generator", () => { + const offenders = modules.flatMap((rel) => + moduleImports(parse(rel, readSrc(rel))) + .value.filter((spec) => FORBIDDEN_SPEC.test(spec)) + .map((spec) => `${rel} → ${spec}`), + ); + expect(offenders).toEqual([]); + }); + + it("does not load the comprehensive generator transitively", () => { + const closure = staticImportClosure(modules); + expect(closure.size).toBeGreaterThan(modules.length); + expect(closure.has(GENERATOR)).toBe(false); + }); + + it("catches a module that loads it (planted)", () => { + expect( + staticImportClosure(["lib/jobs/morning-digest-refresh.ts"]).has( + GENERATOR, + ), + ).toBe(true); + }); +}); diff --git a/src/__tests__/workout-sport-provenance-guard.test.ts b/src/__tests__/workout-sport-provenance-guard.test.ts index 5bb08c40b..addf8f02f 100644 --- a/src/__tests__/workout-sport-provenance-guard.test.ts +++ b/src/__tests__/workout-sport-provenance-guard.test.ts @@ -83,7 +83,7 @@ const PROVIDER_INGESTS: ProviderIngest[] = [ provider: "Polar", mapper: "mapPolarSportType", mapperFile: "lib/polar/sport-map.ts", - captureFile: "lib/polar/client.ts", + captureFile: "lib/polar/map.ts", captureField: "detailed_sport_info", writeFile: "lib/polar/sync-workouts.ts", metadataKey: "polarSport", @@ -92,7 +92,7 @@ const PROVIDER_INGESTS: ProviderIngest[] = [ provider: "Strava", mapper: "mapStravaSportType", mapperFile: "lib/strava/sport-map.ts", - captureFile: "lib/strava/client.ts", + captureFile: "lib/strava/map.ts", captureField: "rawSportType", writeFile: "lib/strava/sync.ts", metadataKey: "stravaType", diff --git a/src/app/api/admin/backups/route.ts b/src/app/api/admin/backups/route.ts index 0aac898e9..495777c14 100644 --- a/src/app/api/admin/backups/route.ts +++ b/src/app/api/admin/backups/route.ts @@ -32,10 +32,10 @@ import { import { readBackupPassActivity } from "@/lib/jobs/backup-pass-attempts"; import { summariseBackupSchedule } from "@/lib/jobs/backup-schedule-status"; import { - OFFHOST_BACKUP_QUEUE, offhostBackupConfigured, probeOffhostLifecycle, } from "@/lib/jobs/offhost-backup"; +import { OFFHOST_BACKUP_QUEUE } from "@/lib/jobs/offhost-backup-queue"; import { classifyOffhostBackup, OFFHOST_BACKUP_PERIOD_HOURS, diff --git a/src/app/api/admin/encryption/rotate/route.ts b/src/app/api/admin/encryption/rotate/route.ts index e3f8dc063..98ded9eef 100644 --- a/src/app/api/admin/encryption/rotate/route.ts +++ b/src/app/api/admin/encryption/rotate/route.ts @@ -31,10 +31,10 @@ import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { - ENCRYPTION_KEY_ROTATE_QUEUE, ENCRYPTION_KEY_ROTATE_SINGLETON, ENCRYPTION_KEY_ROTATE_EXPIRE_SECONDS, } from "@/lib/jobs/encryption-key-rotate"; +import { ENCRYPTION_KEY_ROTATE_QUEUE } from "@/lib/jobs/encryption-key-rotate-queue"; import { getActiveKeyId } from "@/lib/crypto"; import { NextRequest } from "next/server"; diff --git a/src/app/api/admin/import-apple-health-export/route.ts b/src/app/api/admin/import-apple-health-export/route.ts index 798061698..ddddadf5d 100644 --- a/src/app/api/admin/import-apple-health-export/route.ts +++ b/src/app/api/admin/import-apple-health-export/route.ts @@ -21,12 +21,12 @@ import { auditLog } from "@/lib/auth/audit"; import { checkRateLimit } from "@/lib/rate-limit"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { - APPLE_HEALTH_IMPORT_V2_QUEUE, APPLE_HEALTH_IMPORT_KIND, APPLE_HEALTH_IMPORT_PARSER_REVISION, APPLE_HEALTH_IMPORT_SEND_OPTIONS, type AppleHealthImportPayload, } from "@/lib/jobs/apple-health-import-worker"; +import { APPLE_HEALTH_IMPORT_V2_QUEUE } from "@/lib/jobs/apple-health-import-queue"; import { streamMultipartToDisk } from "@/lib/multipart/stream-to-disk"; import { createImportJobUnlessBusy, diff --git a/src/app/api/fitbit/callback/route.ts b/src/app/api/fitbit/callback/route.ts index a8284ae92..57e99199d 100644 --- a/src/app/api/fitbit/callback/route.ts +++ b/src/app/api/fitbit/callback/route.ts @@ -7,15 +7,12 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { encrypt } from "@/lib/crypto"; -import { - exchangeCode, - fetchProfile, - resolveFitbitUserId, - FITBIT_OAUTH_SCOPE, -} from "@/lib/fitbit/client"; +import { fetchProfile, resolveFitbitUserId } from "@/lib/fitbit/client"; +import { fitbitOAuth } from "@/lib/fitbit/oauth"; +import { FITBIT_OAUTH_SCOPE } from "@/lib/fitbit/oauth"; import { getUserFitbitCredentials } from "@/lib/fitbit/credentials"; import { FITBIT_OAUTH_STATE_COOKIE } from "@/lib/fitbit/oauth-state"; -import { FITBIT_BACKFILL_QUEUE } from "@/lib/jobs/fitbit-backfill"; +import { FITBIT_BACKFILL_QUEUE } from "@/lib/fitbit/jobs/queues"; import { enqueueWhenBossReady } from "@/lib/jobs/boss-ready"; import { markReconnected } from "@/lib/integrations/status"; import { Prisma } from "@/generated/prisma/client"; @@ -150,7 +147,9 @@ export const GET = apiHandler(async (request: NextRequest) => { return ERR("nocreds"); } - const tokens = await exchangeCode(code, consumed.codeVerifier, creds); + const tokens = await fitbitOAuth.exchange(code, creds, { + codeVerifier: consumed.codeVerifier, + }); // The initial code exchange MUST return a refresh token. If Fitbit ever // omits it, refuse to persist — a stored empty refresh token silently bricks diff --git a/src/app/api/fitbit/connect/__tests__/route.test.ts b/src/app/api/fitbit/connect/__tests__/route.test.ts index 003b50857..f73409069 100644 --- a/src/app/api/fitbit/connect/__tests__/route.test.ts +++ b/src/app/api/fitbit/connect/__tests__/route.test.ts @@ -27,10 +27,15 @@ vi.mock("@/lib/fitbit/credentials", () => ({ getUserFitbitCredentials: vi.fn(), })); -vi.mock("@/lib/fitbit/client", () => ({ - getAuthorizationUrl: vi.fn( - () => "https://www.fitbit.com/oauth2/authorize?state=nonce-stub", - ), +vi.mock("@/lib/fitbit/oauth", () => ({ + fitbitOAuth: { + authorizationUrl: vi.fn( + () => "https://www.fitbit.com/oauth2/authorize?state=nonce-stub", + ), + }, +})); + +vi.mock("@/lib/integrations/oauth-kit/pkce", () => ({ generatePkcePair: vi.fn(() => ({ verifier: "verifier-stub", challenge: "challenge-stub", diff --git a/src/app/api/fitbit/connect/route.ts b/src/app/api/fitbit/connect/route.ts index 11823963b..f38a1a556 100644 --- a/src/app/api/fitbit/connect/route.ts +++ b/src/app/api/fitbit/connect/route.ts @@ -2,7 +2,8 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { prisma } from "@/lib/db"; import { annotate, getEvent } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; -import { getAuthorizationUrl, generatePkcePair } from "@/lib/fitbit/client"; +import { fitbitOAuth } from "@/lib/fitbit/oauth"; +import { generatePkcePair } from "@/lib/integrations/oauth-kit/pkce"; import { getUserFitbitCredentials } from "@/lib/fitbit/credentials"; import { FITBIT_OAUTH_STATE_COOKIE, @@ -94,7 +95,7 @@ export const GET = apiHandler(async (req: NextRequest) => { ); } - const url = getAuthorizationUrl(nonce, creds, pkce.challenge); + const url = fitbitOAuth.authorizationUrl(nonce, creds, pkce.challenge); const response = NextResponse.redirect(url); response.cookies.set(FITBIT_OAUTH_STATE_COOKIE, nonce, { diff --git a/src/app/api/google-health/callback/route.ts b/src/app/api/google-health/callback/route.ts index 4c0ac3d98..97947aa72 100644 --- a/src/app/api/google-health/callback/route.ts +++ b/src/app/api/google-health/callback/route.ts @@ -10,14 +10,14 @@ import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { encrypt } from "@/lib/crypto"; import { - exchangeCode, fetchProfile, resolveGoogleHealthUserId, - getGoogleHealthScopeString, } from "@/lib/google-health/client"; +import { googleHealthOAuth } from "@/lib/google-health/oauth"; +import { getGoogleHealthScopeString } from "@/lib/google-health/oauth"; import { getUserGoogleHealthCredentials } from "@/lib/google-health/credentials"; import { GOOGLE_HEALTH_OAUTH_STATE_COOKIE } from "@/lib/google-health/oauth-state"; -import { GOOGLE_HEALTH_BACKFILL_QUEUE } from "@/lib/jobs/google-health-backfill"; +import { GOOGLE_HEALTH_BACKFILL_QUEUE } from "@/lib/jobs/google-health-backfill-queue"; import { enqueueWhenBossReady } from "@/lib/jobs/boss-ready"; import { markReconnected } from "@/lib/integrations/status"; import { Prisma } from "@/generated/prisma/client"; @@ -162,14 +162,14 @@ export const GET = apiHandler(async (request: NextRequest) => { return ERR("nocreds"); } - const tokens = await exchangeCode(code, consumed.codeVerifier, creds); + const tokens = await googleHealthOAuth.exchange(code, creds, { + codeVerifier: consumed.codeVerifier, + }); - // The initial code exchange MUST return a refresh token (the connect route - // forces `prompt=consent` + `access_type=offline`). If Google ever omits - // it, refuse to persist — a stored empty refresh token silently bricks - // every future token refresh (the refresh POST then 400s and the - // connection dies the moment the access token expires). Error out cleanly - // so the user re-consents rather than landing a dead connection. + // The exchange must return a refresh token (the connect route forces + // `prompt=consent` and `access_type=offline`). A stored empty one would + // fail every later refresh, so the person re-consents instead of landing + // a connection that dies with its first access token. if (!tokens.refresh_token) { getEvent()?.addWarning( `google-health callback: token response carried no refresh_token for user ${recordUserId}`, diff --git a/src/app/api/import/apple-health-export/route.ts b/src/app/api/import/apple-health-export/route.ts index 20eb69061..d5e35b179 100644 --- a/src/app/api/import/apple-health-export/route.ts +++ b/src/app/api/import/apple-health-export/route.ts @@ -30,12 +30,12 @@ import { emitSignal } from "@/lib/logging/signal"; import { checkRateLimit } from "@/lib/rate-limit"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { - APPLE_HEALTH_IMPORT_V2_QUEUE, APPLE_HEALTH_IMPORT_KIND, APPLE_HEALTH_IMPORT_PARSER_REVISION, APPLE_HEALTH_IMPORT_SEND_OPTIONS, type AppleHealthImportPayload, } from "@/lib/jobs/apple-health-import-worker"; +import { APPLE_HEALTH_IMPORT_V2_QUEUE } from "@/lib/jobs/apple-health-import-queue"; import { streamMultipartToDisk } from "@/lib/multipart/stream-to-disk"; import { createImportJobUnlessBusy, diff --git a/src/app/api/import/health-connect-export/route.ts b/src/app/api/import/health-connect-export/route.ts index d8eb2b6b8..594c7fe1c 100644 --- a/src/app/api/import/health-connect-export/route.ts +++ b/src/app/api/import/health-connect-export/route.ts @@ -28,10 +28,10 @@ import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { HEALTH_CONNECT_IMPORT_KIND, HEALTH_CONNECT_IMPORT_PARSER_REVISION, - HEALTH_CONNECT_IMPORT_QUEUE, HEALTH_CONNECT_IMPORT_SEND_OPTIONS, type HealthConnectImportPayload, } from "@/lib/jobs/health-connect-import-worker"; +import { HEALTH_CONNECT_IMPORT_QUEUE } from "@/lib/jobs/health-connect-import-queue"; import { streamMultipartToDisk } from "@/lib/multipart/stream-to-disk"; import { createImportJobUnlessBusy, diff --git a/src/app/api/integrations/google-health/test/route.ts b/src/app/api/integrations/google-health/test/route.ts index 79bc9694b..1641dd0d0 100644 --- a/src/app/api/integrations/google-health/test/route.ts +++ b/src/app/api/integrations/google-health/test/route.ts @@ -5,13 +5,13 @@ import { checkRateLimit } from "@/lib/rate-limit"; import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; import { getValidToken } from "@/lib/google-health/sync-core"; +import { fetchDailyRollUp } from "@/lib/google-health/client"; import { GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE, - GOOGLE_HEALTH_API_BASE, - GOOGLE_HEALTH_DATA_TYPES, - fetchDailyRollUp, fetchDataPoints, -} from "@/lib/google-health/client"; +} from "@/lib/google-health/data-points"; +import { GOOGLE_HEALTH_API_BASE } from "@/lib/google-health/request"; +import { GOOGLE_HEALTH_DATA_TYPES } from "@/lib/google-health/mappers"; import { GoogleHealthApiError } from "@/lib/google-health/response-classifier"; import { resolveUserTimezone } from "@/lib/tz/resolver"; import { safeFetch, SafeFetchError } from "@/lib/safe-fetch"; diff --git a/src/app/api/integrations/status/route.ts b/src/app/api/integrations/status/route.ts index 1fe7cf99c..417037307 100644 --- a/src/app/api/integrations/status/route.ts +++ b/src/app/api/integrations/status/route.ts @@ -50,7 +50,7 @@ import { } from "@/lib/withings/plan-features"; import { getPolarClientCredentials } from "@/lib/polar/credentials"; import { getStravaClientCredentials } from "@/lib/strava/credentials"; -import { hasActivityScope } from "@/lib/withings/client"; +import { hasActivityScope } from "@/lib/withings/oauth"; import { loadConnectionHealth } from "@/lib/integrations/connection-health-load"; import { googleHealthBackfillEndedList, diff --git a/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts index 9966968a8..e04be16be 100644 --- a/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts @@ -63,10 +63,8 @@ import { auditLog } from "@/lib/auth/audit"; import { getSession } from "@/lib/auth/session"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { checkRateLimit } from "@/lib/rate-limit"; -import { - MEDICATION_INTAKE_IMPORT_QUEUE, - MEDICATION_INTAKE_IMPORT_STALE_AFTER_MS, -} from "@/lib/jobs/medication-intake-import"; +import { MEDICATION_INTAKE_IMPORT_STALE_AFTER_MS } from "@/lib/jobs/medication-intake-import"; +import { MEDICATION_INTAKE_IMPORT_QUEUE } from "@/lib/jobs/medication-intake-import-queue"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, diff --git a/src/app/api/oura/callback/__tests__/route.test.ts b/src/app/api/oura/callback/__tests__/route.test.ts index 215b7a481..649642a27 100644 --- a/src/app/api/oura/callback/__tests__/route.test.ts +++ b/src/app/api/oura/callback/__tests__/route.test.ts @@ -74,13 +74,15 @@ const { vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: bossMock })); -vi.mock("@/lib/oura/client", async (importOriginal) => ({ - isAcceptedOuraIssuer: ( - await importOriginal() - ).isAcceptedOuraIssuer, - exchangeCode: exchangeMock, +vi.mock("@/lib/oura/client", () => ({ fetchOuraAccountId: vi.fn(async () => "oura-account-1"), })); +vi.mock("@/lib/oura/oauth", async (importOriginal) => ({ + isAcceptedOuraIssuer: ( + await importOriginal() + ).isAcceptedOuraIssuer, + exchangeOuraCode: exchangeMock, +})); vi.mock("@/lib/oura/credentials", () => ({ getOuraClientCredentials: getCredsMock, })); diff --git a/src/app/api/oura/callback/route.ts b/src/app/api/oura/callback/route.ts index 26b2e677e..61d3634fe 100644 --- a/src/app/api/oura/callback/route.ts +++ b/src/app/api/oura/callback/route.ts @@ -6,11 +6,8 @@ import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { encrypt } from "@/lib/crypto"; -import { - exchangeCode, - fetchOuraAccountId, - isAcceptedOuraIssuer, -} from "@/lib/oura/client"; +import { fetchOuraAccountId } from "@/lib/oura/client"; +import { exchangeOuraCode, isAcceptedOuraIssuer } from "@/lib/oura/oauth"; import { ALREADY_LINKED_REASON, accountLinkedElsewhere, @@ -27,10 +24,8 @@ import { markReconnected } from "@/lib/integrations/status"; import { OURA_SYNC_QUEUE } from "@/lib/jobs/integration-poll-queues"; import { enqueueWhenBossReady } from "@/lib/jobs/boss-ready"; import { integrationBackfillSourceOptions } from "@/lib/jobs/integration-backfill-admission"; -import { - OURA_BACKFILL_QUEUE, - type OuraBackfillPayload, -} from "@/lib/jobs/oura-backfill"; +import { type OuraBackfillPayload } from "@/lib/jobs/oura-backfill"; +import { OURA_BACKFILL_QUEUE } from "@/lib/jobs/oura-backfill-queue"; import { NextRequest, NextResponse } from "next/server"; /** @@ -139,7 +134,7 @@ async function completeCallback( const creds = await getOuraClientCredentials(userId); if (!creds) return ERR("nocreds"); - const tokens = await exchangeCode(code, creds, iss); + const tokens = await exchangeOuraCode(code, creds, iss); // One Oura account, one record: a guardian signed in to their own Oura // account must not attach it to the profile they manage. diff --git a/src/app/api/polar/callback/__tests__/route.test.ts b/src/app/api/polar/callback/__tests__/route.test.ts index 84c255e1a..061746acf 100644 --- a/src/app/api/polar/callback/__tests__/route.test.ts +++ b/src/app/api/polar/callback/__tests__/route.test.ts @@ -76,9 +76,11 @@ vi.mock("@/lib/crypto", () => ({ })); vi.mock("@/lib/auth/session", () => ({ getSession: getSessionMock })); vi.mock("@/lib/polar/client", () => ({ - exchangeCode: exchangeCodeMock, registerUser: registerUserMock, })); +vi.mock("@/lib/polar/oauth", () => ({ + polarOAuth: { exchange: exchangeCodeMock }, +})); vi.mock("@/lib/polar/credentials", () => ({ getPolarClientCredentials: getCredsMock, })); diff --git a/src/app/api/polar/callback/route.ts b/src/app/api/polar/callback/route.ts index 84230b171..4aae7e385 100644 --- a/src/app/api/polar/callback/route.ts +++ b/src/app/api/polar/callback/route.ts @@ -9,7 +9,8 @@ import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { encrypt } from "@/lib/crypto"; -import { exchangeCode, registerUser } from "@/lib/polar/client"; +import { registerUser } from "@/lib/polar/client"; +import { polarOAuth } from "@/lib/polar/oauth"; import { getPolarClientCredentials } from "@/lib/polar/credentials"; import { oauthStateCookieName, @@ -115,7 +116,7 @@ async function completeCallback( const creds = await getPolarClientCredentials(userId); if (!creds) return ERR("nocreds"); - const tokens = await exchangeCode(code, creds); + const tokens = await polarOAuth.exchange(code, creds); // Guard against an off-spec token body without `x_user_id`: persisting the // string "undefined" as the member id would break every later data read // (`/v3/users/undefined/...`) with no clear cause. diff --git a/src/app/api/strava/callback/__tests__/route.test.ts b/src/app/api/strava/callback/__tests__/route.test.ts index a2f0f15de..a73794284 100644 --- a/src/app/api/strava/callback/__tests__/route.test.ts +++ b/src/app/api/strava/callback/__tests__/route.test.ts @@ -83,7 +83,9 @@ vi.mock("@/lib/logging/context", () => ({ })); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn(async () => {}) })); vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc(${s})` })); -vi.mock("@/lib/strava/client", () => ({ exchangeCode: exchangeCodeMock })); +vi.mock("@/lib/strava/oauth", () => ({ + stravaOAuth: { exchange: exchangeCodeMock }, +})); vi.mock("@/lib/strava/credentials", () => ({ getStravaClientCredentials: getCredsMock, })); diff --git a/src/app/api/strava/callback/route.ts b/src/app/api/strava/callback/route.ts index e94544c3c..99c8d1624 100644 --- a/src/app/api/strava/callback/route.ts +++ b/src/app/api/strava/callback/route.ts @@ -9,7 +9,7 @@ import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { encrypt } from "@/lib/crypto"; -import { exchangeCode } from "@/lib/strava/client"; +import { stravaOAuth } from "@/lib/strava/oauth"; import { getStravaClientCredentials } from "@/lib/strava/credentials"; import { oauthStateCookieName, @@ -18,7 +18,7 @@ import { } from "@/lib/oauth/signed-state"; import { runOAuthCallbackOnce } from "@/lib/oauth/callback-claim"; import { markReconnected } from "@/lib/integrations/status"; -import { STRAVA_BACKFILL_QUEUE } from "@/lib/jobs/strava-backfill"; +import { STRAVA_BACKFILL_QUEUE } from "@/lib/jobs/strava-backfill-queue"; import { enqueueWhenBossReady } from "@/lib/jobs/boss-ready"; import { NextRequest, NextResponse } from "next/server"; @@ -114,7 +114,7 @@ async function completeCallback( const creds = await getStravaClientCredentials(userId); if (!creds) return ERR("nocreds"); - const tokens = await exchangeCode(code, creds); + const tokens = await stravaOAuth.exchange(code, creds); const athleteId = typeof tokens.athlete?.id === "number" ? String(tokens.athlete.id) : null; diff --git a/src/app/api/whoop/callback/__tests__/route.test.ts b/src/app/api/whoop/callback/__tests__/route.test.ts index 7ceec9334..58aed091c 100644 --- a/src/app/api/whoop/callback/__tests__/route.test.ts +++ b/src/app/api/whoop/callback/__tests__/route.test.ts @@ -52,15 +52,19 @@ vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc:${s}` })); vi.mock("@/lib/whoop/client", () => ({ - WHOOP_OAUTH_SCOPE: "read:recovery", - exchangeCode: vi.fn(async () => ({ - access_token: "at", - refresh_token: "rt", - expires_in: 3600, - scope: "read:recovery", - })), fetchProfile: vi.fn(async () => ({ user_id: 42 })), })); +vi.mock("@/lib/whoop/oauth", () => ({ + WHOOP_OAUTH_SCOPE: "read:recovery", + whoopOAuth: { + exchange: vi.fn(async () => ({ + access_token: "at", + refresh_token: "rt", + expires_in: 3600, + scope: "read:recovery", + })), + }, +})); vi.mock("@/lib/whoop/credentials", () => ({ getUserWhoopCredentials: vi.fn(async () => ({ diff --git a/src/app/api/whoop/callback/route.ts b/src/app/api/whoop/callback/route.ts index 9bf1cef17..36da98e8b 100644 --- a/src/app/api/whoop/callback/route.ts +++ b/src/app/api/whoop/callback/route.ts @@ -6,15 +6,12 @@ import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { encrypt } from "@/lib/crypto"; -import { - exchangeCode, - fetchProfile, - WHOOP_OAUTH_SCOPE, -} from "@/lib/whoop/client"; +import { fetchProfile } from "@/lib/whoop/client"; +import { WHOOP_OAUTH_SCOPE, whoopOAuth } from "@/lib/whoop/oauth"; import { getUserWhoopCredentials } from "@/lib/whoop/credentials"; import { WHOOP_OAUTH_STATE_COOKIE } from "@/lib/whoop/oauth-state"; import { buildReturnSchemeRedirect } from "@/lib/whoop/return-scheme"; -import { WHOOP_BACKFILL_QUEUE } from "@/lib/jobs/whoop-backfill"; +import { WHOOP_BACKFILL_QUEUE } from "@/lib/jobs/whoop-backfill-queue"; import { enqueueWhenBossReady } from "@/lib/jobs/boss-ready"; import { markReconnected } from "@/lib/integrations/status"; import { Prisma } from "@/generated/prisma/client"; @@ -205,7 +202,7 @@ export const GET = apiHandler(async (request: NextRequest) => { return outcomeRedirect(returnScheme, "error", "nocreds"); } - const tokens = await exchangeCode(code, creds); + const tokens = await whoopOAuth.exchange(code, creds); const profile = await fetchProfile(tokens.access_token); const whoopUserId = String(profile.user_id); const expiresAt = new Date(Date.now() + tokens.expires_in * 1000); diff --git a/src/app/api/withings/callback/__tests__/route.test.ts b/src/app/api/withings/callback/__tests__/route.test.ts index 98b9b96c1..89fd74f07 100644 --- a/src/app/api/withings/callback/__tests__/route.test.ts +++ b/src/app/api/withings/callback/__tests__/route.test.ts @@ -81,8 +81,8 @@ vi.mock("@/lib/crypto", () => ({ encrypt: vi.fn((s: string) => `enc(${s})`), })); -vi.mock("@/lib/withings/client", () => ({ - exchangeCode: vi.fn(), +vi.mock("@/lib/withings/oauth", () => ({ + withingsOAuth: { exchange: vi.fn() }, WITHINGS_OAUTH_SCOPE: "user.metrics,user.activity", })); @@ -157,8 +157,8 @@ describe("withings/callback atomic nonce consumption (M3)", () => { clientId: "cid", clientSecret: "csec", } as never); - const { exchangeCode } = await import("@/lib/withings/client"); - vi.mocked(exchangeCode).mockResolvedValue({ + const { withingsOAuth } = await import("@/lib/withings/oauth"); + vi.mocked(withingsOAuth.exchange).mockResolvedValue({ userid: "wuid", access_token: "atok", refresh_token: "rtok", @@ -221,8 +221,8 @@ describe("withings/callback atomic nonce consumption (M3)", () => { clientId: "cid", clientSecret: "csec", } as never); - const { exchangeCode } = await import("@/lib/withings/client"); - vi.mocked(exchangeCode).mockResolvedValue({ + const { withingsOAuth } = await import("@/lib/withings/oauth"); + vi.mocked(withingsOAuth.exchange).mockResolvedValue({ userid: "wuid", access_token: "atok", refresh_token: "rtok", @@ -300,7 +300,7 @@ describe("withings/callback atomic nonce consumption (M3)", () => { expiredRow as never, ); - const { exchangeCode } = await import("@/lib/withings/client"); + const { withingsOAuth } = await import("@/lib/withings/oauth"); const req = callbackRequest(NONCE, NONCE); const res = await GET(req); @@ -310,7 +310,7 @@ describe("withings/callback atomic nonce consumption (M3)", () => { "withings=error&reason=expired", ); // Single atomic round-trip consumed the row; no token exchange. - expect(exchangeCode).not.toHaveBeenCalled(); + expect(withingsOAuth.exchange).not.toHaveBeenCalled(); expect(annotateMock).toHaveBeenCalledWith({ meta: { reason: "expired" } }); }); @@ -325,7 +325,7 @@ describe("withings/callback atomic nonce consumption (M3)", () => { crossUserRow as never, ); - const { exchangeCode } = await import("@/lib/withings/client"); + const { withingsOAuth } = await import("@/lib/withings/oauth"); const req = callbackRequest(NONCE, NONCE); const res = await GET(req); @@ -334,7 +334,7 @@ describe("withings/callback atomic nonce consumption (M3)", () => { expect(res.headers.get("location")).toContain( "withings=error&reason=cross_user", ); - expect(exchangeCode).not.toHaveBeenCalled(); + expect(withingsOAuth.exchange).not.toHaveBeenCalled(); expect(annotateMock).toHaveBeenCalledWith({ meta: { reason: "cross_user" }, }); diff --git a/src/app/api/withings/callback/route.ts b/src/app/api/withings/callback/route.ts index 07522df24..335a3b3c6 100644 --- a/src/app/api/withings/callback/route.ts +++ b/src/app/api/withings/callback/route.ts @@ -9,7 +9,7 @@ import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { encrypt } from "@/lib/crypto"; -import { exchangeCode, WITHINGS_OAUTH_SCOPE } from "@/lib/withings/client"; +import { WITHINGS_OAUTH_SCOPE, withingsOAuth } from "@/lib/withings/oauth"; import { getUserWithingsCredentials } from "@/lib/withings/credentials"; import { WITHINGS_OAUTH_STATE_COOKIE } from "@/lib/withings/oauth-state"; import { setupWebhook } from "@/lib/withings/sync"; @@ -209,7 +209,7 @@ export const GET = apiHandler(async (request: NextRequest) => { ); } - const tokens = await exchangeCode(code, creds); + const tokens = await withingsOAuth.exchange(code, creds); // One Withings account, one record. A guardian signed in to their own // Health Mate account must not attach it to the profile they manage: diff --git a/src/app/api/withings/status/route.ts b/src/app/api/withings/status/route.ts index 3af9d631f..943b91cf6 100644 --- a/src/app/api/withings/status/route.ts +++ b/src/app/api/withings/status/route.ts @@ -3,7 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate, getEvent } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load"; -import { hasActivityScope } from "@/lib/withings/client"; +import { hasActivityScope } from "@/lib/withings/oauth"; import { obtainAccessToken } from "@/lib/integrations/oauth-kit/token"; import { withingsTokenDescriptor } from "@/lib/withings/oauth-descriptor"; import { isWithingsRefreshReauthFailure } from "@/lib/withings/sync"; diff --git a/src/lib/fitbit/__tests__/client.test.ts b/src/lib/fitbit/__tests__/client.test.ts index 30668375c..62f14d191 100644 --- a/src/lib/fitbit/__tests__/client.test.ts +++ b/src/lib/fitbit/__tests__/client.test.ts @@ -4,13 +4,9 @@ import { FITBIT_API_BASE, FITBIT_RETRY_AFTER_WAIT_CAP_S, FITBIT_FIELD_MAP, - FITBIT_OAUTH_SCOPE, - exchangeCode, fetchSleepRange, fetchSpo2Range, fetchWeightRange, - generatePkcePair, - getAuthorizationUrl, mapActiveCalories, mapBodyFat, mapDistance, @@ -29,9 +25,11 @@ import { parseVo2Max, readActivityList, readSleepSessions, - refreshAccessToken, resolveFitbitUserId, } from "../client"; +import { FITBIT_OAUTH_SCOPE } from "@/lib/fitbit/oauth"; +import { fitbitOAuth } from "../oauth"; +import { generatePkcePair } from "@/lib/integrations/oauth-kit/pkce"; const CREDS = { clientId: "cid", clientSecret: "csecret" }; @@ -89,7 +87,7 @@ describe("generatePkcePair", () => { describe("getAuthorizationUrl", () => { it("builds the classic Fitbit authorize URL with PKCE S256", () => { - const url = getAuthorizationUrl("nonce123", CREDS, "chal-xyz"); + const url = fitbitOAuth.authorizationUrl("nonce123", CREDS, "chal-xyz"); expect(url).toContain("www.fitbit.com/oauth2/authorize"); expect(url).toContain("response_type=code"); expect(url).toContain("client_id=cid"); @@ -118,7 +116,7 @@ describe("getAuthorizationUrl", () => { }); it("carries the configured app origin in the redirect_uri", () => { - const url = getAuthorizationUrl("nonce123", CREDS, "c"); + const url = fitbitOAuth.authorizationUrl("nonce123", CREDS, "c"); const redirect = new URL(url).searchParams.get("redirect_uri"); expect(redirect).toBe("https://app.example.test/api/fitbit/callback"); }); @@ -133,12 +131,14 @@ describe("redirect_uri allowlist", () => { it("rejects a non-https app origin", () => { process.env.NEXT_PUBLIC_APP_URL = "http://app.example.test"; - expect(() => getAuthorizationUrl("n", CREDS, "c")).toThrow(/must be https/); + expect(() => fitbitOAuth.authorizationUrl("n", CREDS, "c")).toThrow( + /must be https/, + ); }); it("allows http on localhost (dev)", () => { process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000"; - const url = getAuthorizationUrl("n", CREDS, "c"); + const url = fitbitOAuth.authorizationUrl("n", CREDS, "c"); expect(new URL(url).searchParams.get("redirect_uri")).toBe( "http://localhost:3000/api/fitbit/callback", ); @@ -146,7 +146,7 @@ describe("redirect_uri allowlist", () => { it("rejects an explicit redirect that targets the wrong path", () => { process.env.FITBIT_REDIRECT_URI = "https://app.example.test/evil"; - expect(() => getAuthorizationUrl("n", CREDS, "c")).toThrow( + expect(() => fitbitOAuth.authorizationUrl("n", CREDS, "c")).toThrow( /must target \/api\/fitbit\/callback/, ); }); @@ -155,7 +155,7 @@ describe("redirect_uri allowlist", () => { process.env.NEXT_PUBLIC_APP_URL = "https://app.example.test"; process.env.FITBIT_REDIRECT_URI = "https://attacker.example.test/api/fitbit/callback"; - expect(() => getAuthorizationUrl("n", CREDS, "c")).toThrow( + expect(() => fitbitOAuth.authorizationUrl("n", CREDS, "c")).toThrow( /does not match/, ); }); @@ -175,7 +175,9 @@ describe("token exchange + refresh", () => { }, }, ]); - const tok = await exchangeCode("code", "verifier-123", CREDS); + const tok = await fitbitOAuth.exchange("code", CREDS, { + codeVerifier: "verifier-123", + }); expect(tok.access_token).toBe("at"); expect(tok.refresh_token).toBe("rt"); expect(tok.expires_in).toBe(28800); @@ -202,7 +204,7 @@ describe("token exchange + refresh", () => { }, }, ]); - const tok = await refreshAccessToken("rt1", CREDS); + const tok = await fitbitOAuth.refresh("rt1", CREDS); expect(tok.access_token).toBe("at2"); // Classic Fitbit rotates: a fresh refresh token is returned every time. expect(tok.refresh_token).toBe("rt2"); @@ -220,7 +222,9 @@ describe("token exchange + refresh", () => { installFetchMock([ { status: 401, body: { errors: [{ errorType: "invalid_grant" }] } }, ]); - await expect(exchangeCode("bad", "v", CREDS)).rejects.toMatchObject({ + await expect( + fitbitOAuth.exchange("bad", CREDS, { codeVerifier: "v" }), + ).rejects.toMatchObject({ name: "FitbitApiError", classification: "reauth_required", }); @@ -231,7 +235,9 @@ describe("token exchange + refresh", () => { // TypeError. It must surface as a classified transient integration error. it("classifies a 2xx with an empty body instead of casting null", async () => { installFetchMock([{ status: 200, body: null }]); - await expect(exchangeCode("code", "v", CREDS)).rejects.toMatchObject({ + await expect( + fitbitOAuth.exchange("code", CREDS, { codeVerifier: "v" }), + ).rejects.toMatchObject({ name: "FitbitApiError", classification: "transient", reason: "empty_token_body", diff --git a/src/lib/fitbit/__tests__/poll-cohort.test.ts b/src/lib/fitbit/__tests__/poll-cohort.test.ts index c042846ec..16d8ef160 100644 --- a/src/lib/fitbit/__tests__/poll-cohort.test.ts +++ b/src/lib/fitbit/__tests__/poll-cohort.test.ts @@ -33,7 +33,7 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({ recomputeBucketsForMeasurement: vi.fn(async () => {}), recomputeUserRollups: vi.fn(async () => ({ rowsUpserted: 0, durationMs: 0 })), })); -vi.mock("@/lib/insights/comprehensive-generate", () => ({ +vi.mock("@/lib/insights/status-invalidation", () => ({ invalidateStatusInsightsForTypes: vi.fn(async () => {}), })); diff --git a/src/lib/fitbit/__tests__/setup-docs-parity.test.ts b/src/lib/fitbit/__tests__/setup-docs-parity.test.ts index 6dffa3198..09a5b4f26 100644 --- a/src/lib/fitbit/__tests__/setup-docs-parity.test.ts +++ b/src/lib/fitbit/__tests__/setup-docs-parity.test.ts @@ -3,9 +3,9 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { GOOGLE_HEALTH_CORE_SCOPES } from "@/lib/google-health/client"; +import { GOOGLE_HEALTH_CORE_SCOPES } from "@/lib/google-health/oauth"; -import { FITBIT_OAUTH_SCOPE } from "../client"; +import { FITBIT_OAUTH_SCOPE } from "@/lib/fitbit/oauth"; /** * Guard against the class of bug reported in #396: the setup docs drifting away diff --git a/src/lib/fitbit/__tests__/sync-dead-token.test.ts b/src/lib/fitbit/__tests__/sync-dead-token.test.ts index ece6ddf3a..d80c5d007 100644 --- a/src/lib/fitbit/__tests__/sync-dead-token.test.ts +++ b/src/lib/fitbit/__tests__/sync-dead-token.test.ts @@ -60,7 +60,7 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({ recomputeBucketsForMeasurement: vi.fn(async () => {}), recomputeUserRollups: vi.fn(async () => {}), })); -vi.mock("@/lib/insights/comprehensive-generate", () => ({ +vi.mock("@/lib/insights/status-invalidation", () => ({ invalidateStatusInsightsForTypes: vi.fn(async () => {}), })); vi.mock("@/lib/logging/context", () => ({ @@ -70,7 +70,9 @@ vi.mock("@/lib/logging/context", () => ({ vi.mock("../credentials", () => ({ getUserFitbitCredentials: getUserFitbitCredentialsMock, })); -vi.mock("../client", () => ({ refreshAccessToken: refreshAccessTokenMock })); +vi.mock("../oauth", () => ({ + fitbitOAuth: { refresh: refreshAccessTokenMock }, +})); vi.mock("../sync-metrics", () => ({ syncUserMetrics: resourceFake })); vi.mock("../sync-activity", () => ({ syncUserActivity: resourceFake })); diff --git a/src/lib/fitbit/__tests__/sync-plausibility.test.ts b/src/lib/fitbit/__tests__/sync-plausibility.test.ts index 7efe59f99..0d2fa95b0 100644 --- a/src/lib/fitbit/__tests__/sync-plausibility.test.ts +++ b/src/lib/fitbit/__tests__/sync-plausibility.test.ts @@ -35,7 +35,7 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({ recomputeBucketsForMeasurement: vi.fn(async () => {}), recomputeUserRollups: vi.fn(async () => {}), })); -vi.mock("@/lib/insights/comprehensive-generate", () => ({ +vi.mock("@/lib/insights/status-invalidation", () => ({ invalidateStatusInsightsForTypes: vi.fn(async () => {}), })); vi.mock("@/lib/integrations/oauth-refresh", () => ({ @@ -44,7 +44,13 @@ vi.mock("@/lib/integrations/oauth-refresh", () => ({ vi.mock("../credentials", () => ({ getUserFitbitCredentials: vi.fn(async () => null), })); -vi.mock("../client", () => ({ refreshAccessToken: vi.fn() })); +vi.mock("../oauth", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fitbitOAuth: { ...actual.fitbitOAuth, refresh: vi.fn() }, + }; +}); import { eventStorage } from "@/lib/logging/context"; import { WideEventBuilder } from "@/lib/logging/event-builder"; diff --git a/src/lib/fitbit/__tests__/sync-sleep-replace.test.ts b/src/lib/fitbit/__tests__/sync-sleep-replace.test.ts index 4586a24c2..14a961fbe 100644 --- a/src/lib/fitbit/__tests__/sync-sleep-replace.test.ts +++ b/src/lib/fitbit/__tests__/sync-sleep-replace.test.ts @@ -60,7 +60,7 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({ recomputeBucketsForMeasurement: vi.fn(async () => {}), recomputeUserRollups: vi.fn(async () => {}), })); -vi.mock("@/lib/insights/comprehensive-generate", () => ({ +vi.mock("@/lib/insights/status-invalidation", () => ({ invalidateStatusInsightsForTypes: vi.fn(async () => {}), })); vi.mock("@/lib/integrations/oauth-refresh", () => ({ diff --git a/src/lib/fitbit/__tests__/sync-upsert-resurrect.test.ts b/src/lib/fitbit/__tests__/sync-upsert-resurrect.test.ts index 508bf739c..9efdde5cc 100644 --- a/src/lib/fitbit/__tests__/sync-upsert-resurrect.test.ts +++ b/src/lib/fitbit/__tests__/sync-upsert-resurrect.test.ts @@ -48,7 +48,7 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({ recomputeBucketsForMeasurement: vi.fn(async () => {}), recomputeUserRollups: vi.fn(async () => {}), })); -vi.mock("@/lib/insights/comprehensive-generate", () => ({ +vi.mock("@/lib/insights/status-invalidation", () => ({ invalidateStatusInsightsForTypes: vi.fn(async () => {}), })); vi.mock("@/lib/integrations/oauth-refresh", () => ({ @@ -57,7 +57,13 @@ vi.mock("@/lib/integrations/oauth-refresh", () => ({ vi.mock("../credentials", () => ({ getUserFitbitCredentials: vi.fn(async () => null), })); -vi.mock("../client", () => ({ refreshAccessToken: vi.fn() })); +vi.mock("../oauth", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fitbitOAuth: { ...actual.fitbitOAuth, refresh: vi.fn() }, + }; +}); import { upsertFitbitMeasurements } from "../sync-core"; diff --git a/src/lib/fitbit/__tests__/sync-user-fitbit.test.ts b/src/lib/fitbit/__tests__/sync-user-fitbit.test.ts index 2be8cada5..c1a2299f5 100644 --- a/src/lib/fitbit/__tests__/sync-user-fitbit.test.ts +++ b/src/lib/fitbit/__tests__/sync-user-fitbit.test.ts @@ -73,7 +73,7 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({ recomputeBucketsForMeasurement: vi.fn(async () => {}), recomputeUserRollups: (...a: unknown[]) => recomputeUserRollups(...a), })); -vi.mock("@/lib/insights/comprehensive-generate", () => ({ +vi.mock("@/lib/insights/status-invalidation", () => ({ invalidateStatusInsightsForTypes: vi.fn(async () => {}), })); diff --git a/src/lib/fitbit/__tests__/sync.test.ts b/src/lib/fitbit/__tests__/sync.test.ts index 9d8990a3b..d0e38d630 100644 --- a/src/lib/fitbit/__tests__/sync.test.ts +++ b/src/lib/fitbit/__tests__/sync.test.ts @@ -43,10 +43,11 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({ collapseToTypeDayKeys: (rows: Array<{ type: string; measuredAt: Date }>) => rows, recomputeBucketsForMeasurement: vi.fn(async () => {}), + recomputeBucketsForKeys: vi.fn(async () => {}), recomputeUserRollups: vi.fn(async () => ({ rowsUpserted: 0, durationMs: 0 })), })); -vi.mock("@/lib/insights/comprehensive-generate", () => ({ +vi.mock("@/lib/insights/status-invalidation", () => ({ invalidateStatusInsightsForTypes: vi.fn(async () => {}), })); @@ -55,11 +56,14 @@ vi.mock("@/lib/crypto", () => ({ decrypt: (s: string) => s.replace(/^enc\(|\)$/g, ""), })); -vi.mock("../client", async (orig) => { - const actual = await orig(); +vi.mock("../oauth", async (orig) => { + const actual = await orig(); return { ...actual, - refreshAccessToken: (...a: unknown[]) => refreshAccessTokenMock(...a), + fitbitOAuth: { + ...actual.fitbitOAuth, + refresh: (...a: unknown[]) => refreshAccessTokenMock(...a), + }, }; }); diff --git a/src/lib/fitbit/client.ts b/src/lib/fitbit/client.ts index 5f936c61d..5e55799d6 100644 --- a/src/lib/fitbit/client.ts +++ b/src/lib/fitbit/client.ts @@ -36,19 +36,15 @@ * self-serve Google path exists, the classic API is the only viable transport * for self-hosters; the card keeps its experimental badge. */ -import { createHash, randomBytes } from "node:crypto"; import { getEvent } from "@/lib/logging/context"; import { safeFetch } from "@/lib/safe-fetch"; import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz"; import { zonedWallClockToUtc } from "@/lib/tz/wall-clock"; import { FitbitApiError, classifyFitbitResponse } from "./response-classifier"; -import { envValue } from "@/lib/env"; import { importValue } from "@/lib/integrations/import-value"; /** Classic Fitbit Web API base. */ export const FITBIT_API_BASE = "https://api.fitbit.com"; -const FITBIT_OAUTH_AUTH_URL = "https://www.fitbit.com/oauth2/authorize"; -const FITBIT_OAUTH_TOKEN_URL = "https://api.fitbit.com/oauth2/token"; /** * Fitbit returns body weight / distance in metric (kg / km) only when an @@ -58,269 +54,6 @@ const FITBIT_OAUTH_TOKEN_URL = "https://api.fitbit.com/oauth2/token"; */ const FITBIT_METRIC_LOCALE = "en_GB"; -export interface FitbitCredentials { - clientId: string; - clientSecret: string; -} - -/** - * Resolve the OAuth `redirect_uri`, then assert it against an allowlist - * (defence-in-depth; carried over from the prior transport). - * - * The value is operator-controlled config (`FITBIT_REDIRECT_URI`, else derived - * from `NEXT_PUBLIC_APP_URL`), not user input, and Fitbit's registered-redirect - * check is the real backstop. But a misconfigured or `Host`-coerced - * `NEXT_PUBLIC_APP_URL` (a mis-deployed reverse proxy reflecting a forwarded - * Host) would otherwise send the authorization code's landing URL off-origin. - * Pin the target so a malformed origin fails fast at the handshake: - * - must be an absolute, parseable URL, - * - must be https (the one exception is a localhost/loopback dev host), - * - must land on the fixed `/api/fitbit/callback` path, - * - when derived from `NEXT_PUBLIC_APP_URL`, must stay same-origin with it. - */ -export function getFitbitRedirectUri(): string { - // An empty or blank value counts as unset: the compose whitelist - // materialises an unset var as an empty string, which must fall through to - // the derived URI rather than read as "not configured". - const explicit = envValue("FITBIT_REDIRECT_URI"); - const appUrl = envValue("NEXT_PUBLIC_APP_URL"); - const raw = - explicit ?? (appUrl ? `${appUrl}/api/fitbit/callback` : undefined); - - if (!raw) { - throw new Error( - "Fitbit redirect_uri is not configured — set FITBIT_REDIRECT_URI or NEXT_PUBLIC_APP_URL", - ); - } - - let parsed: URL; - try { - parsed = new URL(raw); - } catch { - throw new Error(`Fitbit redirect_uri is not an absolute URL: ${raw}`); - } - - const isLoopback = - parsed.hostname === "localhost" || - parsed.hostname === "127.0.0.1" || - parsed.hostname === "[::1]" || - parsed.hostname === "::1"; - if ( - parsed.protocol !== "https:" && - !(parsed.protocol === "http:" && isLoopback) - ) { - throw new Error( - `Fitbit redirect_uri must be https (or http on localhost): ${parsed.origin}`, - ); - } - - if (parsed.pathname !== "/api/fitbit/callback") { - throw new Error( - `Fitbit redirect_uri must target /api/fitbit/callback, got ${parsed.pathname}`, - ); - } - - // When an explicit FITBIT_REDIRECT_URI is set alongside NEXT_PUBLIC_APP_URL, - // require them to share an origin so the pinned value can't drift to an - // unexpected host relative to the app's own base URL. - if (explicit && appUrl) { - let appOrigin: string; - try { - appOrigin = new URL(appUrl).origin; - } catch { - throw new Error(`NEXT_PUBLIC_APP_URL is not an absolute URL: ${appUrl}`); - } - if (parsed.origin !== appOrigin) { - throw new Error( - `Fitbit redirect_uri origin ${parsed.origin} does not match NEXT_PUBLIC_APP_URL origin ${appOrigin}`, - ); - } - } - - return parsed.toString(); -} - -/** - * Classic Fitbit OAuth scopes HealthLog requests (space-separated on the wire). - * Each is independently self-serve grantable at the consent screen and the - * `temperature` set is omitted on purpose (see the skin-temperature note in - * `mapping.md` — the classic skin-temp reading is a baseline DELTA, not an - * absolute reading, so it has no honest canonical slot here). The launch bundle - * covers every metric the v1.20.0 mappers extract. - */ -export const FITBIT_OAUTH_SCOPE = [ - "activity", - "cardio_fitness", - "heartrate", - "oxygen_saturation", - "profile", - "respiratory_rate", - "sleep", - "weight", -].join(" "); - -// ─── PKCE ────────────────────────────────────────────────────── -// -// Fitbit recommends PKCE (S256). The verifier is a high-entropy random string; -// the challenge is BASE64URL(SHA256(verifier)). The verifier is stashed on the -// OAuth-state row at connect and presented on the token exchange at callback. - -export interface FitbitPkcePair { - verifier: string; - challenge: string; -} - -/** - * Mint a PKCE verifier + S256 challenge. 64 random bytes → 86 base64url chars, - * comfortably inside Fitbit's 43–128 char verifier range and well past the - * 256-bit entropy floor. - */ -export function generatePkcePair(): FitbitPkcePair { - const verifier = randomBytes(64).toString("base64url"); - const challenge = createHash("sha256").update(verifier).digest("base64url"); - return { verifier, challenge }; -} - -/** - * Build the Fitbit authorization URL (a browser redirect, not a fetch). `state` - * is the opaque CSRF nonce minted by `oauth-state.ts`; `codeChallenge` is the - * S256 PKCE challenge whose verifier the callback presents on token exchange. - */ -export function getAuthorizationUrl( - state: string, - creds: FitbitCredentials, - codeChallenge: string, -): string { - const params = new URLSearchParams({ - response_type: "code", - client_id: creds.clientId, - redirect_uri: getFitbitRedirectUri(), - scope: FITBIT_OAUTH_SCOPE, - code_challenge: codeChallenge, - code_challenge_method: "S256", - state, - }); - return `${FITBIT_OAUTH_AUTH_URL}?${params}`; -} - -export interface FitbitTokenResponse { - access_token: string; - /** - * Classic Fitbit refresh tokens ROTATE (one-time use) — every token response - * (initial exchange AND every refresh) carries a fresh `refresh_token` that - * MUST replace the stored one. Typed optional only to guard a malformed - * response defensively; the callers treat an absent value as a hard error / - * keep-existing fallback. - */ - refresh_token?: string; - expires_in: number; - scope?: string; - token_type?: string; - user_id?: string; -} - -/** Basic-auth header carrying the confidential client credentials. */ -function basicAuthHeader(creds: FitbitCredentials): string { - const raw = `${creds.clientId}:${creds.clientSecret}`; - return `Basic ${Buffer.from(raw, "utf8").toString("base64")}`; -} - -async function postToken( - params: URLSearchParams, - creds: FitbitCredentials, - verb: string, -): Promise { - const start = performance.now(); - const res = await safeFetch(FITBIT_OAUTH_TOKEN_URL, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Authorization: basicAuthHeader(creds), - }, - body: params.toString(), - }); - - const json = await res.json().catch(() => null); - const verdict = classifyFitbitResponse(res.status); - getEvent()?.addExternalCall({ - service: "fitbit", - method: verb, - duration_ms: Math.round(performance.now() - start), - status: res.status, - error: verdict.classification === "success" ? undefined : verdict.reason, - }); - if (verdict.classification !== "success") { - throw new FitbitApiError({ - verb, - classification: verdict.classification, - httpStatus: verdict.httpStatus, - reason: verdict.reason, - upstreamError: - typeof json?.errors?.[0]?.errorType === "string" - ? json.errors[0].errorType - : typeof json?.error === "string" - ? json.error - : undefined, - }); - } - // A 2xx with an empty or non-JSON body (captive portal, gateway 200, 204) - // leaves `json` null. Casting it straight to the token type handed callers a - // null they then dereferenced, surfacing an unclassified TypeError instead of - // a handled integration error. Narrow explicitly and classify as transient. - if (json === null || typeof json !== "object") { - throw new FitbitApiError({ - verb, - classification: "transient", - httpStatus: res.status, - reason: "empty_token_body", - }); - } - return json as FitbitTokenResponse; -} - -/** - * Exchange an authorization code for the initial token pair, presenting the PKCE - * verifier. `redirect_uri` must exactly match the one sent to the authorize - * endpoint. - */ -export async function exchangeCode( - code: string, - codeVerifier: string, - creds: FitbitCredentials, -): Promise { - return postToken( - new URLSearchParams({ - grant_type: "authorization_code", - code, - client_id: creds.clientId, - redirect_uri: getFitbitRedirectUri(), - code_verifier: codeVerifier, - }), - creds, - "exchangeCode", - ); -} - -/** - * Refresh an expired access token. Classic Fitbit ROTATES the refresh token: - * the response carries a fresh `access_token` AND a fresh `refresh_token`; the - * caller MUST persist the new refresh token (the old one is now invalid). The - * grant's scope is preserved, so no `scope` param is re-sent. - */ -export async function refreshAccessToken( - refreshToken: string, - creds: FitbitCredentials, -): Promise { - return postToken( - new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - }), - creds, - "refreshAccessToken", - ); -} - /** * The Fitbit profile carries the stable external user id (`encodedId`) used as * the connection's `fitbitUserId`. A single GET, not a paginated collection. diff --git a/src/lib/fitbit/credentials.ts b/src/lib/fitbit/credentials.ts index 6725daef8..bdb14b15c 100644 --- a/src/lib/fitbit/credentials.ts +++ b/src/lib/fitbit/credentials.ts @@ -10,7 +10,7 @@ */ import { prisma } from "@/lib/db"; import { decrypt } from "@/lib/crypto"; -import type { FitbitCredentials } from "./client"; +import type { FitbitCredentials } from "./oauth"; /** * Fetch and decrypt the user's Fitbit API credentials. diff --git a/src/lib/jobs/__tests__/fitbit-backfill.test.ts b/src/lib/fitbit/jobs/__tests__/backfill.test.ts similarity index 99% rename from src/lib/jobs/__tests__/fitbit-backfill.test.ts rename to src/lib/fitbit/jobs/__tests__/backfill.test.ts index fb1720bfa..6f17a4431 100644 --- a/src/lib/jobs/__tests__/fitbit-backfill.test.ts +++ b/src/lib/fitbit/jobs/__tests__/backfill.test.ts @@ -42,7 +42,7 @@ vi.mock("@/lib/logging/context", () => ({ import { enqueueBootTimeFitbitBackfill, runFitbitBackfillForUser, -} from "../fitbit-backfill"; +} from "@/lib/fitbit/jobs/backfill"; import { isFitbitRetired } from "@/lib/fitbit/retirement"; beforeEach(() => { diff --git a/src/lib/fitbit/jobs/__tests__/queues.test.ts b/src/lib/fitbit/jobs/__tests__/queues.test.ts new file mode 100644 index 000000000..6f6db73f9 --- /dev/null +++ b/src/lib/fitbit/jobs/__tests__/queues.test.ts @@ -0,0 +1,117 @@ +/** + * pg-boss queue registration for the Fitbit sync layer. + * + * Reads the worker registry instead of booting the worker: every Fitbit queue + * is declared in the integration-sync group with its cron and handler, the + * full-history queues forward to the shared admission lane, and the boot + * discovery offers the backfill and the sleep repair again. This is the fast + * guard for the dead-queue class (a queue declared but never registered, + * which silently never drains). + * + * Fitbit is poll-only at launch (no webhook — Pub/Sub deferred), so a single + * hourly `fitbit-sync` queue drives the per-user `syncUserFitbit` driver across + * every connection, alongside the backfill + the daily OAuth-state sweep. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + FITBIT_BACKFILL_QUEUE, + FITBIT_OAUTH_STATE_CLEANUP_CRON, + FITBIT_OAUTH_STATE_CLEANUP_QUEUE, + FITBIT_SLEEP_REPAIR_QUEUE, + FITBIT_SYNC_CRON, + FITBIT_SYNC_QUEUE, +} from "@/lib/fitbit/jobs/queues"; +import { INTEGRATION_BACKFILL_ADMISSION_QUEUE } from "@/lib/jobs/integration-backfill-admission"; +import { + handleFitbitOAuthStateCleanup, + handleFitbitSync, +} from "@/lib/fitbit/jobs/sync-handlers"; +import { cronIsTheRetry } from "@/lib/jobs/reminder/registrar-shared"; +import { + discoveryOf, + groupOf, + jobOf, + loadedHandler, + recordingBoss, +} from "@/lib/jobs/registry/__tests__/registry-helpers"; + +const handlerSource = readFileSync( + join(__dirname, "..", "sync-handlers.ts"), + "utf8", +); + +const FITBIT_QUEUES = [ + FITBIT_SYNC_QUEUE, + FITBIT_BACKFILL_QUEUE, + FITBIT_SLEEP_REPAIR_QUEUE, + FITBIT_OAUTH_STATE_CLEANUP_QUEUE, +] as const; + +/** The admission sends the source queue's handler makes for one job. */ +async function forwardOneJob(queue: string) { + const { boss, sends } = recordingBoss(); + const handler = await jobOf(queue).load({ boss }); + await handler([ + { + id: "j1", + name: queue, + data: { userId: "u1" }, + expireInSeconds: 60, + heartbeatSeconds: null, + retryCount: 0, + signal: new AbortController().signal, + }, + ]); + return sends; +} + +describe("Fitbit sync queues", () => { + it("declares the Fitbit sync + oauth-state-cleanup queue constants", () => { + expect(FITBIT_SYNC_QUEUE).toBe("fitbit-sync"); + expect(FITBIT_OAUTH_STATE_CLEANUP_QUEUE).toBe("fitbit-oauth-state-cleanup"); + }); + + it("declares EVERY Fitbit queue in the integration-sync group", () => { + for (const q of FITBIT_QUEUES) { + expect(groupOf(q).name).toBe("integration_sync"); + } + }); + + it("schedules the hourly poll cron + the daily oauth-state sweep", () => { + expect(jobOf(FITBIT_SYNC_QUEUE).cron).toBe(FITBIT_SYNC_CRON); + expect(jobOf(FITBIT_SYNC_QUEUE).scheduleOptions).toBeUndefined(); + const sweep = jobOf(FITBIT_OAUTH_STATE_CLEANUP_QUEUE); + expect(sweep.cron).toBe(FITBIT_OAUTH_STATE_CLEANUP_CRON); + expect(sweep.scheduleOptions).toBe(cronIsTheRetry); + }); + + it("binds a handler for every Fitbit queue", async () => { + expect(await loadedHandler(FITBIT_SYNC_QUEUE)).toBe(handleFitbitSync); + expect(await loadedHandler(FITBIT_OAUTH_STATE_CLEANUP_QUEUE)).toBe( + handleFitbitOAuthStateCleanup, + ); + for (const [queue, kind] of [ + [FITBIT_BACKFILL_QUEUE, "fitbit-backfill"], + [FITBIT_SLEEP_REPAIR_QUEUE, "fitbit-sleep-repair"], + ] as const) { + const sends = await forwardOneJob(queue); + expect(sends).toHaveLength(1); + expect(sends[0]!.queue).toBe(INTEGRATION_BACKFILL_ADMISSION_QUEUE); + expect(sends[0]!.data).toMatchObject({ kind, data: { userId: "u1" } }); + } + }); + + it("wires the self-converging backfill and the one-shot sleep-repair boot discovery", () => { + const passes = discoveryOf("integration_sync"); + expect(passes).toContain("fitbit-backfill"); + expect(passes).toContain("fitbit-sleep-repair"); + }); + + it("imports the Fitbit sync driver into the handler module", () => { + expect(handlerSource).toMatch(/from\s*["']@\/lib\/fitbit\/sync["']/); + }); +}); diff --git a/src/lib/jobs/__tests__/fitbit-sleep-repair.test.ts b/src/lib/fitbit/jobs/__tests__/sleep-repair.test.ts similarity index 96% rename from src/lib/jobs/__tests__/fitbit-sleep-repair.test.ts rename to src/lib/fitbit/jobs/__tests__/sleep-repair.test.ts index f768c9799..374c6fe43 100644 --- a/src/lib/jobs/__tests__/fitbit-sleep-repair.test.ts +++ b/src/lib/fitbit/jobs/__tests__/sleep-repair.test.ts @@ -50,7 +50,7 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({ recomputeBucketsForMeasurement: vi.fn(async () => {}), recomputeUserRollups: vi.fn(async () => {}), })); -vi.mock("@/lib/insights/comprehensive-generate", () => ({ +vi.mock("@/lib/insights/status-invalidation", () => ({ invalidateStatusInsightsForTypes: vi.fn(async () => {}), })); vi.mock("@/lib/integrations/oauth-refresh", () => ({ @@ -59,7 +59,13 @@ vi.mock("@/lib/integrations/oauth-refresh", () => ({ vi.mock("@/lib/fitbit/credentials", () => ({ getUserFitbitCredentials: vi.fn(async () => null), })); -vi.mock("@/lib/fitbit/client", () => ({ refreshAccessToken: vi.fn() })); +vi.mock("@/lib/fitbit/oauth", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fitbitOAuth: { ...actual.fitbitOAuth, refresh: vi.fn() }, + }; +}); import { FITBIT_BACKFILL_DAYS, @@ -70,7 +76,7 @@ import { import { enqueueBootTimeFitbitSleepRepair, runFitbitSleepRepairForUser, -} from "../fitbit-sleep-repair"; +} from "@/lib/fitbit/jobs/sleep-repair"; import { isFitbitRetired } from "@/lib/fitbit/retirement"; beforeEach(() => { diff --git a/src/lib/jobs/__tests__/fitbit-sync-retired.test.ts b/src/lib/fitbit/jobs/__tests__/sync-retired.test.ts similarity index 95% rename from src/lib/jobs/__tests__/fitbit-sync-retired.test.ts rename to src/lib/fitbit/jobs/__tests__/sync-retired.test.ts index 445930b16..7d5e5ef7f 100644 --- a/src/lib/jobs/__tests__/fitbit-sync-retired.test.ts +++ b/src/lib/fitbit/jobs/__tests__/sync-retired.test.ts @@ -21,7 +21,7 @@ vi.mock("@/lib/jobs/reminder-satisfy", () => ({ enqueueReminderSatisfy: vi.fn(async () => {}), })); -import { handleFitbitSync } from "../reminder/fitbit-sync"; +import { handleFitbitSync } from "@/lib/fitbit/jobs/sync-handlers"; import { isFitbitRetired } from "@/lib/fitbit/retirement"; describe("handleFitbitSync", () => { diff --git a/src/lib/jobs/fitbit-backfill.ts b/src/lib/fitbit/jobs/backfill.ts similarity index 94% rename from src/lib/jobs/fitbit-backfill.ts rename to src/lib/fitbit/jobs/backfill.ts index ad457bfae..69d81a05f 100644 --- a/src/lib/jobs/fitbit-backfill.ts +++ b/src/lib/fitbit/jobs/backfill.ts @@ -21,15 +21,7 @@ import { } from "@/lib/integrations/page-walk"; import { syncUserFitbit } from "@/lib/fitbit/sync"; import { isFitbitRetired } from "@/lib/fitbit/retirement"; - -export const FITBIT_BACKFILL_QUEUE = "fitbit-backfill"; - -/** - * Serial concurrency — a backfill walks years of history for one account and is - * rate-bounded by Google's per-app quota; concurrency-1 keeps it from crowding - * the request pool, matching `WHOOP_BACKFILL_CONCURRENCY`. - */ -export const FITBIT_BACKFILL_CONCURRENCY = 1; +import { FITBIT_BACKFILL_QUEUE } from "./queues"; export interface FitbitBackfillPayload { userId: string; diff --git a/src/lib/fitbit/jobs/definitions.ts b/src/lib/fitbit/jobs/definitions.ts new file mode 100644 index 000000000..c01d6f31e --- /dev/null +++ b/src/lib/fitbit/jobs/definitions.ts @@ -0,0 +1,94 @@ +/** + * Every queue the Fitbit integration runs, as one block of the worker + * registry. + * + * Google turns the classic Fitbit Web API off on 2026-10-30 + * (`lib/fitbit/retirement.ts`). Until the integration is removed it keeps + * syncing; removing it then is this folder plus the one line in + * `jobs/registry/integration-sync.ts` that spreads these definitions in. + */ +import { bootStaggerSecondsFor } from "@/lib/jobs/integration-backfill-admission"; +import { cronIsTheRetry } from "@/lib/jobs/reminder/registrar-shared"; +import { + defineJob, + type BootDiscovery, + type JobDefinition, +} from "@/lib/jobs/registry/define-job"; + +import { + FITBIT_BACKFILL_CONCURRENCY, + FITBIT_BACKFILL_QUEUE, + FITBIT_OAUTH_STATE_CLEANUP_CRON, + FITBIT_OAUTH_STATE_CLEANUP_QUEUE, + FITBIT_SLEEP_REPAIR_CONCURRENCY, + FITBIT_SLEEP_REPAIR_QUEUE, + FITBIT_SYNC_CRON, + FITBIT_SYNC_QUEUE, +} from "./queues"; + +export const FITBIT_JOBS: readonly JobDefinition[] = [ + // The cron walks every connection; serial so a backfill-heavy tick never + // crowds the request pool. + defineJob({ + queue: FITBIT_SYNC_QUEUE, + cron: FITBIT_SYNC_CRON, + work: { localConcurrency: 1 }, + load: async () => (await import("./sync-handlers")).handleFitbitSync, + }), + // The source queue keeps the per-connection singleton and forwards + // admitted work to the shared drain. + defineJob({ + queue: FITBIT_BACKFILL_QUEUE, + policy: { + policy: "exclusive", + reason: + "Per-connection full-history backfill; boot-discovery driven and self-converging.", + }, + work: { localConcurrency: FITBIT_BACKFILL_CONCURRENCY }, + load: async ({ boss }) => + ( + await import("@/lib/jobs/integration-backfill-forward") + ).forwardToBackfillAdmission(boss, "fitbit-backfill"), + }), + // One-shot sleep duplicate repair: a bounded sleep re-read per connection, + // forwarded to the shared drain. + defineJob({ + queue: FITBIT_SLEEP_REPAIR_QUEUE, + policy: { + policy: "exclusive", + reason: + "Per-connection one-shot sleep re-read; discovery drops the connection once it is marked repaired.", + }, + work: { localConcurrency: FITBIT_SLEEP_REPAIR_CONCURRENCY }, + load: async ({ boss }) => + ( + await import("@/lib/jobs/integration-backfill-forward") + ).forwardToBackfillAdmission(boss, "fitbit-sleep-repair"), + }), + defineJob({ + queue: FITBIT_OAUTH_STATE_CLEANUP_QUEUE, + cron: FITBIT_OAUTH_STATE_CLEANUP_CRON, + scheduleOptions: cronIsTheRetry, + work: { localConcurrency: 1 }, + load: async () => + (await import("./sync-handlers")).handleFitbitOAuthStateCleanup, + }), +]; + +/** The Fitbit backfill and sleep repair, offered again at every boot. */ +export const FITBIT_BOOT_DISCOVERY: readonly BootDiscovery[] = [ + { + name: "fitbit-backfill", + run: async () => + (await import("./backfill")).enqueueBootTimeFitbitBackfill( + bootStaggerSecondsFor("fitbit-backfill"), + ), + }, + { + name: "fitbit-sleep-repair", + run: async () => + (await import("./sleep-repair")).enqueueBootTimeFitbitSleepRepair( + bootStaggerSecondsFor("fitbit-sleep-repair"), + ), + }, +]; diff --git a/src/lib/jobs/fitbit-oauth-state-cleanup.ts b/src/lib/fitbit/jobs/oauth-state-cleanup.ts similarity index 100% rename from src/lib/jobs/fitbit-oauth-state-cleanup.ts rename to src/lib/fitbit/jobs/oauth-state-cleanup.ts diff --git a/src/lib/fitbit/jobs/queues.ts b/src/lib/fitbit/jobs/queues.ts new file mode 100644 index 000000000..1bd11c674 --- /dev/null +++ b/src/lib/fitbit/jobs/queues.ts @@ -0,0 +1,30 @@ +/** + * The queue facts of the Fitbit jobs: names and concurrency. They live apart + * from the handlers so the worker registry and every sender can read them + * without loading the Fitbit sync. + */ +export const FITBIT_SLEEP_REPAIR_QUEUE = "fitbit-sleep-repair"; + +/** + * Serial concurrency — a repair walks a year of sleep history for one account + * in 30-day requests against the tight per-user budget; concurrency-1 keeps it + * from crowding the request pool, matching `FITBIT_BACKFILL_CONCURRENCY`. + */ +export const FITBIT_SLEEP_REPAIR_CONCURRENCY = 1; + +export const FITBIT_BACKFILL_QUEUE = "fitbit-backfill"; + +/** + * Serial concurrency — a backfill walks years of history for one account and is + * rate-bounded by Google's per-app quota; concurrency-1 keeps it from crowding + * the request pool, matching `WHOOP_BACKFILL_CONCURRENCY`. + */ +export const FITBIT_BACKFILL_CONCURRENCY = 1; + +/** Hourly Fitbit poll (no webhook). */ +export const FITBIT_SYNC_QUEUE = "fitbit-sync"; +export const FITBIT_SYNC_CRON = "8 * * * *"; + +/** Daily sweep of the Fitbit OAuth state ledger. */ +export const FITBIT_OAUTH_STATE_CLEANUP_QUEUE = "fitbit-oauth-state-cleanup"; +export const FITBIT_OAUTH_STATE_CLEANUP_CRON = "24 3 * * *"; diff --git a/src/lib/jobs/fitbit-sleep-repair.ts b/src/lib/fitbit/jobs/sleep-repair.ts similarity index 95% rename from src/lib/jobs/fitbit-sleep-repair.ts rename to src/lib/fitbit/jobs/sleep-repair.ts index 0b681ff1b..f4919a803 100644 --- a/src/lib/jobs/fitbit-sleep-repair.ts +++ b/src/lib/fitbit/jobs/sleep-repair.ts @@ -46,15 +46,7 @@ import { } from "@/lib/fitbit/sync-core"; import { syncUserSleep } from "@/lib/fitbit/sync-sleep"; import { isFitbitRetired } from "@/lib/fitbit/retirement"; - -export const FITBIT_SLEEP_REPAIR_QUEUE = "fitbit-sleep-repair"; - -/** - * Serial concurrency — a repair walks a year of sleep history for one account - * in 30-day requests against the tight per-user budget; concurrency-1 keeps it - * from crowding the request pool, matching `FITBIT_BACKFILL_CONCURRENCY`. - */ -export const FITBIT_SLEEP_REPAIR_CONCURRENCY = 1; +import { FITBIT_SLEEP_REPAIR_QUEUE } from "./queues"; export interface FitbitSleepRepairPayload { userId: string; diff --git a/src/lib/jobs/reminder/fitbit-sync.ts b/src/lib/fitbit/jobs/sync-handlers.ts similarity index 97% rename from src/lib/jobs/reminder/fitbit-sync.ts rename to src/lib/fitbit/jobs/sync-handlers.ts index 9a560eb38..3b9ea163d 100644 --- a/src/lib/jobs/reminder/fitbit-sync.ts +++ b/src/lib/fitbit/jobs/sync-handlers.ts @@ -11,8 +11,8 @@ import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome"; import { withBackgroundEvent } from "@/lib/logging/background"; import { runFitbitPollCohort } from "@/lib/fitbit/sync"; import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; -import { cleanupExpiredFitbitOAuthStates } from "@/lib/jobs/fitbit-oauth-state-cleanup"; -import { getWorkerPrisma } from "./shared"; +import { cleanupExpiredFitbitOAuthStates } from "./oauth-state-cleanup"; +import { getWorkerPrisma } from "@/lib/jobs/reminder/shared"; import { isFitbitRetired } from "@/lib/fitbit/retirement"; /** diff --git a/src/lib/fitbit/oauth.ts b/src/lib/fitbit/oauth.ts new file mode 100644 index 000000000..c8b66cf70 --- /dev/null +++ b/src/lib/fitbit/oauth.ts @@ -0,0 +1,78 @@ +/** + * The Fitbit Web API OAuth client: consent URL, code exchange and refresh, + * over the shared factory (`integrations/oauth-kit/client.ts`). + * + * S256 PKCE with the confidential client's secret by HTTP Basic beside it. + * Refresh tokens rotate (one-time use): every answer carries a new refresh + * token that replaces the stored one. A refusal names itself in + * `errors[0].errorType`, or in an RFC 6749 `error`. + */ +import { + defineOAuthClient, + oauthErrorCode, + type OAuthClientCredentials, +} from "@/lib/integrations/oauth-kit/client"; +import { checkedRedirectUri } from "@/lib/integrations/oauth-kit/redirect-uri"; + +import { FitbitApiError } from "./response-classifier"; + +export type FitbitCredentials = OAuthClientCredentials; + +/** + * The classic scopes HealthLog requests, space-separated on the wire. Each is + * self-serve grantable; `temperature` is left out on purpose (the classic + * skin-temperature reading is a delta, not an absolute value). + */ +export const FITBIT_OAUTH_SCOPE = [ + "activity", + "cardio_fitness", + "heartrate", + "oxygen_saturation", + "profile", + "respiratory_rate", + "sleep", + "weight", +].join(" "); + +export interface FitbitTokenResponse { + access_token: string; + /** + * Rotates on every answer and must replace the stored one. Optional only to + * guard a malformed answer. + */ + refresh_token?: string; + expires_in: number; + scope?: string; + token_type?: string; + user_id?: string; +} + +function fitbitErrorType(body: unknown): string | undefined { + if (body !== null && typeof body === "object") { + const first = (body as { errors?: Array<{ errorType?: unknown }> }) + .errors?.[0]; + if (typeof first?.errorType === "string") return first.errorType; + } + return oauthErrorCode(body); +} + +export const fitbitOAuth = defineOAuthClient({ + service: "fitbit", + authorizeUrl: "https://www.fitbit.com/oauth2/authorize", + tokenUrl: "https://api.fitbit.com/oauth2/token", + redirectUri: () => + checkedRedirectUri("Fitbit", "FITBIT_REDIRECT_URI", "/api/fitbit/callback"), + authorizeParams: () => ({ scope: FITBIT_OAUTH_SCOPE }), + pkce: true, + clientAuth: { kind: "basic", idInForm: { exchange: true, refresh: false } }, + refresh: true, + upstreamError: fitbitErrorType, + error: (failure) => + new FitbitApiError({ + verb: failure.verb, + classification: failure.classification, + httpStatus: failure.status, + reason: failure.reason, + upstreamError: failure.upstreamError, + }), +}); diff --git a/src/lib/fitbit/sync-core.ts b/src/lib/fitbit/sync-core.ts index d78d1e28d..210f55c96 100644 --- a/src/lib/fitbit/sync-core.ts +++ b/src/lib/fitbit/sync-core.ts @@ -10,17 +10,14 @@ import type { MeasurementType } from "@/generated/prisma/client"; import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit"; import { encrypt, decrypt } from "@/lib/crypto"; import { prisma } from "@/lib/db"; -import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; import { persistRotatedToken } from "@/lib/integrations/oauth-refresh"; import { recordSyncFailure } from "@/lib/integrations/status"; import type { FailureKind } from "@/lib/integrations/status"; import { getEvent } from "@/lib/logging/context"; import { dropImplausibleMeasurements } from "@/lib/measurements/plausibility-gate"; -import { - collapseToTypeDayKeys, - recomputeBucketsForKeys, -} from "@/lib/rollups/measurement-rollups"; -import { refreshAccessToken } from "./client"; +import { collapseToTypeDayKeys } from "@/lib/rollups/measurement-rollups"; +import { afterProviderWrite } from "@/lib/integrations/sync-kit/after-write"; +import { fitbitOAuth } from "./oauth"; import { isFitbitRetired } from "./retirement"; import { getUserFitbitCredentials } from "./credentials"; import { FitbitApiError, classifyFitbitError } from "./response-classifier"; @@ -115,7 +112,7 @@ export async function getValidToken( return null; } - const newTokens = await refreshAccessToken(refreshToken, creds); + const newTokens = await fitbitOAuth.refresh(refreshToken, creds); const expiresAt = new Date(Date.now() + newTokens.expires_in * 1000); // Classic Fitbit ROTATES refresh tokens: the refresh response carries a @@ -898,23 +895,9 @@ export async function upsertFitbitMeasurements( return { imported, touched, inserted: insertedRows }; } - try { - const keys = collapseToTypeDayKeys(touched); - await recomputeBucketsForKeys(userId, keys); - invalidateStatusInsightsForTypes( - userId, - keys.map((k) => k.type), - { source: "sync" }, - ).catch((err) => { - getEvent()?.addWarning( - `fitbit: status-insight invalidate failed for ${userId}: ${err}`, - ); - }); - } catch (err) { - getEvent()?.addWarning( - `fitbit: rollup recompute failed for ${userId}: ${err}`, - ); - } + // The arrivals went out per chunk above; the shared tail folds, marks the + // caches and re-warms the status cards. + await afterProviderWrite({ userId, provider: "fitbit", touched }); return { imported, touched, inserted: insertedRows }; } diff --git a/src/lib/fitbit/sync.ts b/src/lib/fitbit/sync.ts index 3e45ac27d..2a092bf86 100644 --- a/src/lib/fitbit/sync.ts +++ b/src/lib/fitbit/sync.ts @@ -2,10 +2,9 @@ import pLimit from "p-limit"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { prisma } from "@/lib/db"; -import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; import { isReauthRequired, recordSyncSuccess } from "@/lib/integrations/status"; import { annotate, getEvent } from "@/lib/logging/context"; -import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups"; +import { afterProviderBackfill } from "@/lib/integrations/sync-kit/after-write"; import { syncUserActivity } from "./sync-activity"; import { hardFailStorage, @@ -127,22 +126,10 @@ export async function syncUserFitbit( // + 3 queue sends). Best-effort: a populator hiccup never fails the backfill. if (opts.fullSync && deferTracker.keys.size > 0) { try { - const days = Array.from(deferTracker.keys.values()); - const types = Array.from(new Set(days.map((k) => k.type))); - const sorted = days - .map((k) => k.measuredAt.getTime()) - .sort((a, b) => a - b); - const from = new Date(sorted[0]!); - // The keys are UTC day-starts; extend `to` past the last touched day so - // the aggregator's `< to` upper bound covers it. - const to = new Date(sorted[sorted.length - 1]! + 24 * 60 * 60 * 1000); - await recomputeUserRollups(userId, { types, from, to }); - invalidateStatusInsightsForTypes(userId, types, { - source: "sync", - }).catch((err) => { - getEvent()?.addWarning( - `fitbit: status-insight invalidate failed for ${userId}: ${err}`, - ); + await afterProviderBackfill({ + userId, + provider: "fitbit", + keys: Array.from(deferTracker.keys.values()), }); } catch (err) { getEvent()?.addWarning( diff --git a/src/lib/google-health/__tests__/__fixtures__/daily-summaries.json b/src/lib/google-health/__tests__/__fixtures__/daily-summaries.json new file mode 100644 index 000000000..e5cc47305 --- /dev/null +++ b/src/lib/google-health/__tests__/__fixtures__/daily-summaries.json @@ -0,0 +1,94 @@ +[ + { + "name": "users/me/dataTypes/daily-oxygen-saturation/dataPoints/spo2-0001", + "dailyOxygenSaturation": { + "date": { "year": 2026, "month": 6, "day": 1 }, + "averagePercentage": 96.5 + } + }, + { + "name": "users/me/dataTypes/daily-oxygen-saturation/dataPoints/spo2-0002", + "dailyOxygenSaturation": { + "date": "2026-06-02T00:00:00", + "averagePercentage": "97" + } + }, + { + "name": "users/me/dataTypes/daily-heart-rate-variability/dataPoints/hrv-0001", + "dailyHeartRateVariability": { + "date": { "year": 2026, "month": 6, "day": 1 }, + "averageHeartRateVariabilityMilliseconds": 42.123456 + } + }, + { + "name": "users/me/dataTypes/daily-heart-rate-variability/dataPoints/hrv-0002", + "dailyHeartRateVariability": { + "date": { "year": 2026, "month": 12, "day": 31 }, + "averageHeartRateVariabilityMilliseconds": 0 + } + }, + { + "name": "users/me/dataTypes/daily-resting-heart-rate/dataPoints/rhr-0001", + "dailyRestingHeartRate": { + "date": "2026-06-03", + "beatsPerMinute": "55" + } + }, + { + "name": "users/me/dataTypes/daily-resting-heart-rate/dataPoints/rhr-0002", + "dailyRestingHeartRate": { + "date": { "year": 2026, "month": "6", "day": 4 }, + "beatsPerMinute": 57 + } + }, + { + "name": "users/me/dataTypes/daily-respiratory-rate/dataPoints/resp-0001", + "dailyRespiratoryRate": { + "date": { "year": 2026, "month": 3, "day": 29 }, + "breathsPerMinute": 14.8 + } + }, + { + "name": "users/me/dataTypes/daily-respiratory-rate/dataPoints/resp-0002", + "dailyRespiratoryRate": { + "date": { "year": 2026, "month": 3, "day": 30 }, + "dailyRespiratoryRateBpm": 15.1 + } + }, + { + "name": "users/me/dataTypes/daily-sleep-temperature-derivations/dataPoints/temp-0001", + "dailySleepTemperatureDerivations": { + "date": { "year": 2026, "month": 6, "day": 5 }, + "nightlyTemperatureCelsius": 34.42, + "baselineTemperatureCelsius": 34.1, + "relativeNightlyStddev30dCelsius": 0.3 + } + }, + { + "name": "users/me/dataTypes/daily-sleep-temperature-derivations/dataPoints/temp-0002", + "dailySleepTemperatureDerivations": { + "date": "garbage", + "nightlyTemperatureCelsius": 34.6 + } + }, + { + "name": "users/me/dataTypes/daily-vo2-max/dataPoints/vo2-0001", + "dailyVo2Max": { + "date": { "year": 2026, "month": 6, "day": 6 }, + "vo2Max": 47.25 + } + }, + { + "name": "users/me/dataTypes/daily-vo2-max/dataPoints/vo2-0002", + "dailyVo2Max": { + "vo2Max": "45" + } + }, + { + "name": "users/me/dataTypes/daily-vo2-max/dataPoints/vo2-0003", + "dailyVo2Max": { + "date": { "year": 2026, "month": 6, "day": 7 }, + "vo2Max": null + } + } +] diff --git a/src/lib/google-health/__tests__/__fixtures__/exercise.json b/src/lib/google-health/__tests__/__fixtures__/exercise.json new file mode 100644 index 000000000..32c3faaf0 --- /dev/null +++ b/src/lib/google-health/__tests__/__fixtures__/exercise.json @@ -0,0 +1,102 @@ +[ + { + "name": "users/me/dataTypes/exercise/dataPoints/exercise-0001", + "exercise": { + "interval": { + "startTime": "2026-06-02T17:00:00Z", + "endTime": "2026-06-02T17:42:30Z", + "civilStartTime": { + "date": { "year": 2026, "month": 6, "day": 2 }, + "time": { "hours": 19 } + } + }, + "exerciseType": "RUNNING", + "metricsSummary": { + "caloriesKcal": 412.6, + "distanceMillimeters": "7012345", + "averageHeartRateBeatsPerMinute": "151" + } + } + }, + { + "name": "users/me/dataTypes/exercise/dataPoints/exercise-0002", + "exercise": { + "interval": { + "startTime": "2026-06-03T07:00:00", + "endTime": "2026-06-03T08:00:00" + }, + "exerciseType": "strength training", + "metricsSummary": { + "caloriesKcal": "250", + "averageHeartRateBeatsPerMinute": 0 + } + } + }, + { + "exercise": { + "interval": { + "startTime": "2026-06-04T06:30:00+02:00", + "endTime": "2026-06-04T07:15:00+02:00" + }, + "exerciseType": " KITESURFING ", + "metricsSummary": { + "distanceMillimeters": -100, + "averageHeartRateBeatsPerMinute": "128.6" + } + } + }, + { + "name": "users/me/dataTypes/exercise/dataPoints/exercise-0004", + "exercise": { + "interval": { + "startTime": "2026-06-05T12:00:00Z", + "endTime": "2026-06-05T12:30:00Z" + }, + "exerciseType": " " + } + }, + { + "name": "users/me/dataTypes/exercise/dataPoints/exercise-0005", + "exercise": { + "interval": { + "startTime": "2026-06-06T12:00:00Z", + "endTime": "2026-06-06T12:00:00Z" + }, + "exerciseType": "WALKING" + } + }, + { + "name": "users/me/dataTypes/exercise/dataPoints/exercise-0006", + "exercise": { + "interval": { + "startTime": "2026-06-07T12:00:00Z" + }, + "exerciseType": "WALKING" + } + }, + { + "name": "users/me/dataTypes/exercise/dataPoints/exercise-0007", + "exercise": { + "interval": { + "startTime": "2026-06-08T18:00:00Z", + "endTime": "2026-06-08T19:05:00Z" + }, + "exerciseType": "Mountain-Biking", + "metricsSummary": { + "caloriesKcal": 0, + "distanceMillimeters": "0", + "averageHeartRateBeatsPerMinute": "x" + } + } + }, + { + "name": "users/me/dataTypes/exercise/dataPoints/exercise-0008", + "exercise": { + "interval": { + "startTime": "2026-06-09T20:00:00Z", + "endTime": "2026-06-09T20:20:00Z" + }, + "exerciseType": "STAIR_CLIMBER" + } + } +] diff --git a/src/lib/google-health/__tests__/__fixtures__/rollups.json b/src/lib/google-health/__tests__/__fixtures__/rollups.json new file mode 100644 index 000000000..cb0aeb807 --- /dev/null +++ b/src/lib/google-health/__tests__/__fixtures__/rollups.json @@ -0,0 +1,72 @@ +[ + { + "civilStartTime": { + "date": { "year": 2026, "month": 6, "day": 1 }, + "time": {} + }, + "civilEndTime": { + "date": { "year": 2026, "month": 6, "day": 1 }, + "time": { "hours": 23, "minutes": 59, "seconds": 59 } + }, + "steps": { "countSum": "8432" } + }, + { + "civilStartTime": { + "date": { "year": 2026, "month": 6, "day": 2 }, + "time": {} + }, + "steps": { "countSum": "0" } + }, + { + "civilStartTime": "2026-06-03T00:00:00", + "steps": { "countSum": 10250 } + }, + { + "civilEndTime": { + "date": { "year": 2026, "month": 6, "day": 4 } + }, + "steps": { "countSum": "5000" } + }, + { + "civilStartTime": { + "date": { "year": 2026, "month": 6, "day": 1 }, + "time": {} + }, + "distance": { "millimetersSum": "6240512" } + }, + { + "civilStartTime": { + "date": { "year": 2026, "month": 6, "day": 2 }, + "time": {} + }, + "distance": { "millimetersSum": "-1" } + }, + { + "civilStartTime": { + "date": { "year": 2026, "month": 6, "day": 1 }, + "time": {} + }, + "activeEnergyBurned": { "kcalSum": 512.75 } + }, + { + "civilStartTime": { + "date": { "year": 2026, "month": 6, "day": 2 }, + "time": {} + }, + "activeEnergyBurned": {} + }, + { + "civilStartTime": { + "date": { "year": 2026, "month": 12, "day": 31 }, + "time": {} + }, + "floors": { "countSum": "12" } + }, + { + "civilStartTime": { + "date": { "year": 2026, "month": 1, "day": 1 }, + "time": {} + }, + "floors": { "countSum": "" } + } +] diff --git a/src/lib/google-health/__tests__/__fixtures__/samples.json b/src/lib/google-health/__tests__/__fixtures__/samples.json new file mode 100644 index 000000000..0f299ba2f --- /dev/null +++ b/src/lib/google-health/__tests__/__fixtures__/samples.json @@ -0,0 +1,105 @@ +[ + { + "name": "users/me/dataTypes/weight/dataPoints/weight-0001", + "weight": { + "sampleTime": { "physicalTime": "2026-06-02T06:15:00Z" }, + "weightGrams": 78400 + } + }, + { + "name": "users/me/dataTypes/weight/dataPoints/weight-0002", + "weight": { + "sampleTime": { "physicalTime": "2026-06-03T07:30:00+02:00" }, + "weightGrams": "78250.5" + } + }, + { + "name": "users/me/dataTypes/weight/dataPoints/weight-0003", + "weight": { + "sampleTime": { "physicalTime": "2026-06-04T23:45:00" }, + "weightGrams": 0 + } + }, + { + "name": "users/me/dataTypes/weight/dataPoints/weight-0004", + "weight": { + "sampleTime": { "physicalTime": "2026-06-04T23:45:00" }, + "weightGrams": 77900 + } + }, + { + "name": "users/me/dataTypes/body-fat/dataPoints/body-fat-0001", + "bodyFat": { + "sampleTime": { "physicalTime": "2026-06-02T06:15:00.000Z" }, + "percentage": 21.4 + } + }, + { + "name": "users/me/dataTypes/body-fat/dataPoints/body-fat-0002", + "bodyFat": { + "percentage": "20.9" + } + }, + { + "name": "users/me/dataTypes/heart-rate/dataPoints/heart-rate-0001", + "heartRate": { + "sampleTime": { "physicalTime": "2026-06-02T12:00:30-04:00" }, + "beatsPerMinute": "64" + } + }, + { + "name": "users/me/dataTypes/heart-rate/dataPoints/heart-rate-0002", + "heartRate": { + "sampleTime": { "physicalTime": "2026-06-02 00:10" }, + "beatsPerMinute": " " + } + }, + { + "name": "users/me/dataTypes/heart-rate/dataPoints/heart-rate-0003", + "heartRate": { + "sampleTime": { "physicalTime": "not-a-time" }, + "beatsPerMinute": 58 + } + }, + { + "name": "users/me/dataTypes/blood-glucose/dataPoints/blood-glucose-0001", + "bloodGlucose": { + "sampleTime": { "physicalTime": "2026-06-05T08:00:00Z" }, + "bloodGlucoseMilligramsPerDeciliter": 96.333333 + } + }, + { + "name": "users/me/dataTypes/blood-glucose/dataPoints/blood-glucose-0002", + "bloodGlucose": { + "sampleTime": { "physicalTime": "2026-06-05T09:00:00Z" }, + "bloodGlucoseMilligramsPerDeciliter": -5 + } + }, + { + "name": "users/me/dataTypes/core-body-temperature/dataPoints/core-temp-0001", + "coreBodyTemperature": { + "sampleTime": { "physicalTime": "2026-06-06T03:00:00" }, + "temperatureCelsius": 36.75 + } + }, + { + "name": "users/me/dataTypes/height/dataPoints/height-0001", + "height": { + "sampleTime": { "physicalTime": "2026-01-15T10:00:00Z" }, + "heightMillimeters": "1805" + } + }, + { + "name": "users/me/dataTypes/height/dataPoints/height-0002", + "height": { + "heightMillimeters": 1798 + } + }, + { + "name": "users/me/dataTypes/height/dataPoints/height-0003", + "height": { + "sampleTime": { "physicalTime": "2026-01-16T10:00:00Z" }, + "heightMillimeters": "0" + } + } +] diff --git a/src/lib/google-health/__tests__/__fixtures__/sleep.json b/src/lib/google-health/__tests__/__fixtures__/sleep.json new file mode 100644 index 000000000..60837464e --- /dev/null +++ b/src/lib/google-health/__tests__/__fixtures__/sleep.json @@ -0,0 +1,177 @@ +[ + { + "name": "users/me/dataTypes/sleep/dataPoints/sleep-0001", + "sleep": { + "interval": { + "startTime": "2026-06-01T22:30:00Z", + "endTime": "2026-06-02T06:10:00Z" + }, + "stages": [ + { + "startTime": "2026-06-01T22:30:00Z", + "startUtcOffset": "7200s", + "endTime": "2026-06-01T22:45:00Z", + "endUtcOffset": "7200s", + "type": "AWAKE" + }, + { + "startTime": "2026-06-01T22:45:00Z", + "endTime": "2026-06-02T00:15:00Z", + "type": "LIGHT" + }, + { + "startTime": "2026-06-02T00:15:00Z", + "endTime": "2026-06-02T01:20:30Z", + "type": "DEEP" + }, + { + "startTime": "2026-06-02T01:20:30Z", + "endTime": "2026-06-02T02:05:00Z", + "type": "REM" + }, + { + "startTime": "2026-06-02T02:05:00Z", + "endTime": "2026-06-02T06:10:00Z", + "type": "LIGHT" + } + ] + } + }, + { + "name": "users/me/dataTypes/sleep/dataPoints/sleep-0002", + "sleep": { + "interval": { + "startTime": "2026-06-02T23:50:00", + "endTime": "2026-06-03T07:00:00" + }, + "stages": [ + { + "startTime": "2026-06-02T23:50:00", + "endTime": "2026-06-03T00:20:00", + "type": "ASLEEP" + }, + { + "startTime": "2026-06-03T00:20:00", + "endTime": "2026-06-03T00:25:00", + "type": "RESTLESS" + }, + { + "startTime": "2026-06-03T00:25:00", + "endTime": "2026-06-03T07:00:00", + "type": "asleep" + } + ] + } + }, + { + "sleep": { + "interval": { + "startTime": "2026-06-04T22:00:00+02:00", + "endTime": "2026-06-05T05:30:00+02:00" + }, + "stages": [ + { + "startTime": "2026-06-04T22:00:00+02:00", + "endTime": "2026-06-05T01:00:00+02:00", + "type": "DEEP" + }, + { + "startTime": "2026-06-05T01:00:00+02:00", + "endTime": "2026-06-05T05:30:00+02:00", + "type": "SLEEP_STAGE_TYPE_UNSPECIFIED" + } + ] + } + }, + { + "name": "", + "sleep": { + "interval": { + "startTime": "2026-06-06T23:00:00Z" + }, + "stages": [ + { + "startTime": "2026-06-06T23:00:00Z", + "endTime": "2026-06-06T23:00:00Z", + "type": "LIGHT" + }, + { + "startTime": "2026-06-07T01:00:00Z", + "endTime": "2026-06-07T00:30:00Z", + "type": "REM" + }, + { + "startTime": "2026-06-07T01:00:00Z", + "type": "DEEP" + }, + { + "endTime": "2026-06-07T02:00:00Z", + "type": "DEEP" + }, + null, + "LIGHT", + { + "startTime": "2026-06-07T02:00:00Z", + "endTime": "2026-06-07T02:30:00Z" + }, + { + "startTime": "2026-06-07T02:00:00Z", + "endTime": "2026-06-07T02:30:00Z", + "type": 3 + } + ] + } + }, + { + "sleep": { + "stages": "not-a-list" + } + }, + { + "name": "users/me/dataTypes/sleep/dataPoints/sleep-0006", + "sleep": { + "stages": [ + { + "startTime": "2026-06-08T00:00:00Z", + "endTime": "2026-06-08T00:10:00Z", + "type": "NAP_STAGE_ALPHA" + }, + { + "startTime": "2026-06-08T00:10:00Z", + "endTime": "2026-06-08T00:20:00Z", + "type": "NAP_STAGE_BETA" + }, + { + "startTime": "2026-06-08T00:20:00Z", + "endTime": "2026-06-08T00:30:00Z", + "type": "NAP_STAGE_GAMMA" + }, + { + "startTime": "2026-06-08T00:30:00Z", + "endTime": "2026-06-08T00:40:00Z", + "type": "NAP_STAGE_ALPHA" + }, + { + "startTime": "2026-06-08T00:40:00Z", + "endTime": "2026-06-08T00:50:00Z", + "type": "NAP_STAGE_DELTA" + }, + { + "startTime": "2026-06-08T00:50:00Z", + "endTime": "2026-06-08T01:00:00Z", + "type": "NAP_STAGE_EPSILON" + }, + { + "startTime": "2026-06-08T01:00:00Z", + "endTime": "2026-06-08T01:10:00Z", + "type": "NAP_STAGE_ZETA_WITH_A_LABEL_MUCH_LONGER_THAN_FORTY_CHARACTERS" + }, + { + "startTime": "2026-06-08T01:10:00Z", + "endTime": "2026-06-08T01:40:00Z", + "type": "light" + } + ] + } + } +] diff --git a/src/lib/google-health/__tests__/__golden__/map-client-helpers.json b/src/lib/google-health/__tests__/__golden__/map-client-helpers.json new file mode 100644 index 000000000..e124b324e --- /dev/null +++ b/src/lib/google-health/__tests__/__golden__/map-client-helpers.json @@ -0,0 +1,604 @@ +{ + "resolveGoogleHealthUserId": [ + { "profile": { "name": "users/1234567890" }, "userId": "1234567890" }, + { "profile": { "name": "users/", "id": "id-0002" }, "userId": "id-0002" }, + { "profile": { "name": "plain-0003" }, "userId": "plain-0003" }, + { "profile": { "id": "id-0004" }, "userId": "id-0004" }, + { "profile": {}, "userId": "me" } + ], + "formatCivilBound": [ + { + "instant": "2026-06-01T21:59:59.000Z", + "tz": null, + "bound": "2026-06-01T21:59:59" + }, + { + "instant": "2026-06-01T21:59:59.000Z", + "tz": "Europe/Berlin", + "bound": "2026-06-01T23:59:59" + }, + { + "instant": "2026-06-01T21:59:59.000Z", + "tz": "America/New_York", + "bound": "2026-06-01T17:59:59" + }, + { + "instant": "2026-06-01T22:00:00.000Z", + "tz": null, + "bound": "2026-06-01T22:00:00" + }, + { + "instant": "2026-06-01T22:00:00.000Z", + "tz": "Europe/Berlin", + "bound": "2026-06-02T00:00:00" + }, + { + "instant": "2026-06-01T22:00:00.000Z", + "tz": "America/New_York", + "bound": "2026-06-01T18:00:00" + }, + { + "instant": "2026-03-29T00:30:00.000Z", + "tz": null, + "bound": "2026-03-29T00:30:00" + }, + { + "instant": "2026-03-29T00:30:00.000Z", + "tz": "Europe/Berlin", + "bound": "2026-03-29T01:30:00" + }, + { + "instant": "2026-03-29T00:30:00.000Z", + "tz": "America/New_York", + "bound": "2026-03-28T20:30:00" + }, + { + "instant": "2026-12-31T23:30:15.500Z", + "tz": null, + "bound": "2026-12-31T23:30:15" + }, + { + "instant": "2026-12-31T23:30:15.500Z", + "tz": "Europe/Berlin", + "bound": "2027-01-01T00:30:15" + }, + { + "instant": "2026-12-31T23:30:15.500Z", + "tz": "America/New_York", + "bound": "2026-12-31T18:30:15" + } + ], + "civilDateInTz": [ + { + "instant": "2026-06-01T21:59:59.000Z", + "tz": null, + "date": { "year": 2026, "month": 6, "day": 1 } + }, + { + "instant": "2026-06-01T21:59:59.000Z", + "tz": "Europe/Berlin", + "date": { "year": 2026, "month": 6, "day": 1 } + }, + { + "instant": "2026-06-01T21:59:59.000Z", + "tz": "America/New_York", + "date": { "year": 2026, "month": 6, "day": 1 } + }, + { + "instant": "2026-06-01T22:00:00.000Z", + "tz": null, + "date": { "year": 2026, "month": 6, "day": 1 } + }, + { + "instant": "2026-06-01T22:00:00.000Z", + "tz": "Europe/Berlin", + "date": { "year": 2026, "month": 6, "day": 2 } + }, + { + "instant": "2026-06-01T22:00:00.000Z", + "tz": "America/New_York", + "date": { "year": 2026, "month": 6, "day": 1 } + }, + { + "instant": "2026-03-29T00:30:00.000Z", + "tz": null, + "date": { "year": 2026, "month": 3, "day": 29 } + }, + { + "instant": "2026-03-29T00:30:00.000Z", + "tz": "Europe/Berlin", + "date": { "year": 2026, "month": 3, "day": 29 } + }, + { + "instant": "2026-03-29T00:30:00.000Z", + "tz": "America/New_York", + "date": { "year": 2026, "month": 3, "day": 28 } + }, + { + "instant": "2026-12-31T23:30:15.500Z", + "tz": null, + "date": { "year": 2026, "month": 12, "day": 31 } + }, + { + "instant": "2026-12-31T23:30:15.500Z", + "tz": "Europe/Berlin", + "date": { "year": 2027, "month": 1, "day": 1 } + }, + { + "instant": "2026-12-31T23:30:15.500Z", + "tz": "America/New_York", + "date": { "year": 2026, "month": 12, "day": 31 } + } + ], + "incrementalFilter": [ + { + "dataType": "weight", + "style": "camel", + "tz": null, + "filter": { + "field": "weight.sample_time.physical_time", + "bound": "2026-06-01T22:00:00.000Z" + } + }, + { + "dataType": "weight", + "style": "camel", + "tz": "Europe/Berlin", + "filter": { + "field": "weight.sample_time.physical_time", + "bound": "2026-06-01T22:00:00.000Z" + } + }, + { + "dataType": "weight", + "style": "camel", + "tz": "America/New_York", + "filter": { + "field": "weight.sample_time.physical_time", + "bound": "2026-06-01T22:00:00.000Z" + } + }, + { + "dataType": "weight", + "style": "snake", + "tz": null, + "filter": { + "field": "weight.sample_time.physical_time", + "bound": "2026-06-01T22:00:00.000Z" + } + }, + { + "dataType": "weight", + "style": "snake", + "tz": "Europe/Berlin", + "filter": { + "field": "weight.sample_time.physical_time", + "bound": "2026-06-01T22:00:00.000Z" + } + }, + { + "dataType": "weight", + "style": "snake", + "tz": "America/New_York", + "filter": { + "field": "weight.sample_time.physical_time", + "bound": "2026-06-01T22:00:00.000Z" + } + }, + { + "dataType": "daily-heart-rate-variability", + "style": "camel", + "tz": null, + "filter": { + "field": "dailyHeartRateVariability.date", + "bound": "2026-06-01" + } + }, + { + "dataType": "daily-heart-rate-variability", + "style": "camel", + "tz": "Europe/Berlin", + "filter": { + "field": "dailyHeartRateVariability.date", + "bound": "2026-06-01" + } + }, + { + "dataType": "daily-heart-rate-variability", + "style": "camel", + "tz": "America/New_York", + "filter": { + "field": "dailyHeartRateVariability.date", + "bound": "2026-06-01" + } + }, + { + "dataType": "daily-heart-rate-variability", + "style": "snake", + "tz": null, + "filter": { + "field": "daily_heart_rate_variability.date", + "bound": "2026-06-01" + } + }, + { + "dataType": "daily-heart-rate-variability", + "style": "snake", + "tz": "Europe/Berlin", + "filter": { + "field": "daily_heart_rate_variability.date", + "bound": "2026-06-01" + } + }, + { + "dataType": "daily-heart-rate-variability", + "style": "snake", + "tz": "America/New_York", + "filter": { + "field": "daily_heart_rate_variability.date", + "bound": "2026-06-01" + } + }, + { + "dataType": "sleep", + "style": "camel", + "tz": null, + "filter": { + "field": "sleep.interval.end_time", + "bound": "2026-06-01T22:00:00.000Z" + } + }, + { + "dataType": "sleep", + "style": "camel", + "tz": "Europe/Berlin", + "filter": { + "field": "sleep.interval.end_time", + "bound": "2026-06-01T22:00:00.000Z" + } + }, + { + "dataType": "sleep", + "style": "camel", + "tz": "America/New_York", + "filter": { + "field": "sleep.interval.end_time", + "bound": "2026-06-01T22:00:00.000Z" + } + }, + { + "dataType": "sleep", + "style": "snake", + "tz": null, + "filter": { + "field": "sleep.interval.end_time", + "bound": "2026-06-01T22:00:00.000Z" + } + }, + { + "dataType": "sleep", + "style": "snake", + "tz": "Europe/Berlin", + "filter": { + "field": "sleep.interval.end_time", + "bound": "2026-06-01T22:00:00.000Z" + } + }, + { + "dataType": "sleep", + "style": "snake", + "tz": "America/New_York", + "filter": { + "field": "sleep.interval.end_time", + "bound": "2026-06-01T22:00:00.000Z" + } + }, + { + "dataType": "exercise", + "style": "camel", + "tz": null, + "filter": { + "field": "exercise.interval.civil_start_time", + "bound": "2026-06-01T22:00:00" + } + }, + { + "dataType": "exercise", + "style": "camel", + "tz": "Europe/Berlin", + "filter": { + "field": "exercise.interval.civil_start_time", + "bound": "2026-06-02T00:00:00" + } + }, + { + "dataType": "exercise", + "style": "camel", + "tz": "America/New_York", + "filter": { + "field": "exercise.interval.civil_start_time", + "bound": "2026-06-01T18:00:00" + } + }, + { + "dataType": "exercise", + "style": "snake", + "tz": null, + "filter": { + "field": "exercise.interval.civil_start_time", + "bound": "2026-06-01T22:00:00" + } + }, + { + "dataType": "exercise", + "style": "snake", + "tz": "Europe/Berlin", + "filter": { + "field": "exercise.interval.civil_start_time", + "bound": "2026-06-02T00:00:00" + } + }, + { + "dataType": "exercise", + "style": "snake", + "tz": "America/New_York", + "filter": { + "field": "exercise.interval.civil_start_time", + "bound": "2026-06-01T18:00:00" + } + }, + { + "dataType": "steps", + "style": "camel", + "tz": null, + "filter": { + "threw": "Google Health data type steps reads via :dailyRollUp, not dataPoints.list" + } + }, + { + "dataType": "steps", + "style": "camel", + "tz": "Europe/Berlin", + "filter": { + "threw": "Google Health data type steps reads via :dailyRollUp, not dataPoints.list" + } + }, + { + "dataType": "steps", + "style": "camel", + "tz": "America/New_York", + "filter": { + "threw": "Google Health data type steps reads via :dailyRollUp, not dataPoints.list" + } + }, + { + "dataType": "steps", + "style": "snake", + "tz": null, + "filter": { + "threw": "Google Health data type steps reads via :dailyRollUp, not dataPoints.list" + } + }, + { + "dataType": "steps", + "style": "snake", + "tz": "Europe/Berlin", + "filter": { + "threw": "Google Health data type steps reads via :dailyRollUp, not dataPoints.list" + } + }, + { + "dataType": "steps", + "style": "snake", + "tz": "America/New_York", + "filter": { + "threw": "Google Health data type steps reads via :dailyRollUp, not dataPoints.list" + } + } + ], + "incrementalFilterDefaultStyle": { + "field": "dailyRestingHeartRate.date", + "bound": "2026-06-01" + }, + "chunkCivilRange": [ + { + "start": { "year": 2026, "month": 1, "day": 1 }, + "end": { "year": 2026, "month": 7, "day": 1 }, + "maxDays": null, + "chunks": [ + { + "start": { "year": 2026, "month": 1, "day": 1 }, + "end": { "year": 2026, "month": 4, "day": 1 } + }, + { + "start": { "year": 2026, "month": 4, "day": 1 }, + "end": { "year": 2026, "month": 6, "day": 30 } + }, + { + "start": { "year": 2026, "month": 6, "day": 30 }, + "end": { "year": 2026, "month": 7, "day": 1 } + } + ] + }, + { + "start": { "year": 2026, "month": 2, "day": 20 }, + "end": { "year": 2026, "month": 3, "day": 10 }, + "maxDays": 14, + "chunks": [ + { + "start": { "year": 2026, "month": 2, "day": 20 }, + "end": { "year": 2026, "month": 3, "day": 6 } + }, + { + "start": { "year": 2026, "month": 3, "day": 6 }, + "end": { "year": 2026, "month": 3, "day": 10 } + } + ] + }, + { + "start": { "year": 2026, "month": 6, "day": 1 }, + "end": { "year": 2026, "month": 6, "day": 1 }, + "maxDays": null, + "chunks": [] + }, + { + "start": { "year": 2026, "month": 6, "day": 2 }, + "end": { "year": 2026, "month": 6, "day": 1 }, + "maxDays": null, + "chunks": [] + } + ], + "buildDailyRollUpBody": [ + { + "chunk": { + "start": { "year": 2026, "month": 1, "day": 1 }, + "end": { "year": 2026, "month": 4, "day": 1 } + }, + "pageToken": null, + "body": { + "range": { + "start": { + "date": { "year": 2026, "month": 1, "day": 1 }, + "time": { "hours": 0, "minutes": 0, "seconds": 0, "nanos": 0 } + }, + "end": { + "date": { "year": 2026, "month": 3, "day": 31 }, + "time": { "hours": 23, "minutes": 59, "seconds": 59, "nanos": 0 } + } + }, + "windowSizeDays": 1 + } + }, + { + "chunk": { + "start": { "year": 2026, "month": 2, "day": 20 }, + "end": { "year": 2026, "month": 3, "day": 1 } + }, + "pageToken": "page-token-0002", + "body": { + "range": { + "start": { + "date": { "year": 2026, "month": 2, "day": 20 }, + "time": { "hours": 0, "minutes": 0, "seconds": 0, "nanos": 0 } + }, + "end": { + "date": { "year": 2026, "month": 2, "day": 28 }, + "time": { "hours": 23, "minutes": 59, "seconds": 59, "nanos": 0 } + } + }, + "windowSizeDays": 1, + "pageToken": "page-token-0002" + } + }, + { + "chunk": { + "start": { "year": 2026, "month": 12, "day": 31 }, + "end": { "year": 2027, "month": 1, "day": 1 } + }, + "pageToken": "", + "body": { + "range": { + "start": { + "date": { "year": 2026, "month": 12, "day": 31 }, + "time": { "hours": 0, "minutes": 0, "seconds": 0, "nanos": 0 } + }, + "end": { + "date": { "year": 2026, "month": 12, "day": 31 }, + "time": { "hours": 23, "minutes": 59, "seconds": 59, "nanos": 0 } + } + }, + "windowSizeDays": 1 + } + } + ], + "extractGoogleApiErrorDetail": [ + { + "body": { + "error": { + "code": 400, + "status": "INVALID_ARGUMENT", + "message": "Invalid filter for https://health.googleapis.com/v4/users/me/dataTypes/weight/dataPoints?filter=weight.date", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.BadRequest", + "fieldViolations": [ + { + "field": "filter", + "description": "Unknown field weight.date" + }, + { "field": "page_size" }, + { "description": "Authorization: Bearer abc.def.ghi rejected" }, + { "field": "fourth", "description": "dropped past the third" } + ] + } + ] + } + }, + "detail": "INVALID_ARGUMENT: Invalid filter for https://health.googleapis.com/v4/users/me/dataTypes/weight/dataPoints?[REDACTED] [filter: Unknown field weight.date; page_size; Authorization: Bearer [REDACTED] rejected]" + }, + { + "body": { "error": { "status": "RESOURCE_EXHAUSTED" } }, + "detail": "RESOURCE_EXHAUSTED" + }, + { + "body": { + "error": { + "message": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + }, + "detail": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + { + "body": { + "error": { "details": [null, { "fieldViolations": [{}, 5] }] } + }, + "detail": null + }, + { "body": { "error": "invalid_grant" }, "detail": null }, + { "body": { "error": {} }, "detail": null }, + { "body": null, "detail": null }, + { "body": "plain text", "detail": null } + ], + "refusesEdgeFilter": [ + { "detail": null, "refuses": true }, + { "detail": "INVALID_ARGUMENT", "refuses": true }, + { "detail": "INVALID_ARGUMENT: Invalid filter", "refuses": true }, + { "detail": "INVALID_ARGUMENT: Invalid page token", "refuses": false }, + { "detail": "INVALID_ARGUMENT: page_size out of range", "refuses": false }, + { "detail": "INVALID_ARGUMENT: pageSize too large", "refuses": false }, + { "detail": "UNAUTHENTICATED: access token expired", "refuses": false }, + { "detail": "FAILED_PRECONDITION: something else", "refuses": false }, + { "detail": "Bad filter and bad token", "refuses": true } + ], + "googleHealthRetryDelayMs": [ + { "headers": { "retry-after": "30" }, "body": null, "delayMs": 30000 }, + { "headers": { "retry-after": "1.5" }, "body": null, "delayMs": 1500 }, + { + "headers": { "retry-after": "Wed, 01 Jul 2026 09:00:45 GMT" }, + "body": null, + "delayMs": 45000 + }, + { "headers": { "retry-after": "-3" }, "body": null, "delayMs": null }, + { + "headers": {}, + "body": { + "error": { + "details": [ + { "@type": "type.googleapis.com/google.rpc.ErrorInfo" }, + { + "@type": "type.googleapis.com/google.rpc.RetryInfo", + "retryDelay": "12.5s" + } + ] + } + }, + "delayMs": 12500 + }, + { + "headers": {}, + "body": { "error": { "details": [{ "retryDelay": "soon" }] } }, + "delayMs": null + }, + { "headers": {}, "body": null, "delayMs": null } + ] +} diff --git a/src/lib/google-health/__tests__/__golden__/map-daily-summaries.json b/src/lib/google-health/__tests__/__golden__/map-daily-summaries.json new file mode 100644 index 000000000..fabd41605 --- /dev/null +++ b/src/lib/google-health/__tests__/__golden__/map-daily-summaries.json @@ -0,0 +1,457 @@ +[ + { + "index": 0, + "key": "dailyOxygenSaturation", + "tz": null, + "output": [ + { + "type": "OXYGEN_SATURATION", + "value": 96.5, + "unit": "%", + "measuredAt": "2026-06-01T12:00:00.000Z", + "fieldTag": "2026-06-01:spo2" + } + ] + }, + { + "index": 0, + "key": "dailyOxygenSaturation", + "tz": "Europe/Berlin", + "output": [ + { + "type": "OXYGEN_SATURATION", + "value": 96.5, + "unit": "%", + "measuredAt": "2026-06-01T10:00:00.000Z", + "fieldTag": "2026-06-01:spo2" + } + ] + }, + { + "index": 0, + "key": "dailyOxygenSaturation", + "tz": "America/New_York", + "output": [ + { + "type": "OXYGEN_SATURATION", + "value": 96.5, + "unit": "%", + "measuredAt": "2026-06-01T16:00:00.000Z", + "fieldTag": "2026-06-01:spo2" + } + ] + }, + { + "index": 1, + "key": "dailyOxygenSaturation", + "tz": null, + "output": [ + { + "type": "OXYGEN_SATURATION", + "value": 97, + "unit": "%", + "measuredAt": "2026-06-02T12:00:00.000Z", + "fieldTag": "2026-06-02:spo2" + } + ] + }, + { + "index": 1, + "key": "dailyOxygenSaturation", + "tz": "Europe/Berlin", + "output": [ + { + "type": "OXYGEN_SATURATION", + "value": 97, + "unit": "%", + "measuredAt": "2026-06-02T10:00:00.000Z", + "fieldTag": "2026-06-02:spo2" + } + ] + }, + { + "index": 1, + "key": "dailyOxygenSaturation", + "tz": "America/New_York", + "output": [ + { + "type": "OXYGEN_SATURATION", + "value": 97, + "unit": "%", + "measuredAt": "2026-06-02T16:00:00.000Z", + "fieldTag": "2026-06-02:spo2" + } + ] + }, + { + "index": 2, + "key": "dailyHeartRateVariability", + "tz": null, + "output": [ + { + "type": "HRV_RMSSD", + "value": 42.12, + "unit": "ms", + "measuredAt": "2026-06-01T12:00:00.000Z", + "fieldTag": "2026-06-01:hrv" + } + ] + }, + { + "index": 2, + "key": "dailyHeartRateVariability", + "tz": "Europe/Berlin", + "output": [ + { + "type": "HRV_RMSSD", + "value": 42.12, + "unit": "ms", + "measuredAt": "2026-06-01T10:00:00.000Z", + "fieldTag": "2026-06-01:hrv" + } + ] + }, + { + "index": 2, + "key": "dailyHeartRateVariability", + "tz": "America/New_York", + "output": [ + { + "type": "HRV_RMSSD", + "value": 42.12, + "unit": "ms", + "measuredAt": "2026-06-01T16:00:00.000Z", + "fieldTag": "2026-06-01:hrv" + } + ] + }, + { "index": 3, "key": "dailyHeartRateVariability", "tz": null, "output": [] }, + { + "index": 3, + "key": "dailyHeartRateVariability", + "tz": "Europe/Berlin", + "output": [] + }, + { + "index": 3, + "key": "dailyHeartRateVariability", + "tz": "America/New_York", + "output": [] + }, + { + "index": 4, + "key": "dailyRestingHeartRate", + "tz": null, + "output": [ + { + "type": "RESTING_HEART_RATE", + "value": 55, + "unit": "bpm", + "measuredAt": "2026-06-03T12:00:00.000Z", + "fieldTag": "2026-06-03:rhr" + } + ] + }, + { + "index": 4, + "key": "dailyRestingHeartRate", + "tz": "Europe/Berlin", + "output": [ + { + "type": "RESTING_HEART_RATE", + "value": 55, + "unit": "bpm", + "measuredAt": "2026-06-03T10:00:00.000Z", + "fieldTag": "2026-06-03:rhr" + } + ] + }, + { + "index": 4, + "key": "dailyRestingHeartRate", + "tz": "America/New_York", + "output": [ + { + "type": "RESTING_HEART_RATE", + "value": 55, + "unit": "bpm", + "measuredAt": "2026-06-03T16:00:00.000Z", + "fieldTag": "2026-06-03:rhr" + } + ] + }, + { + "index": 5, + "key": "dailyRestingHeartRate", + "tz": null, + "output": [ + { + "type": "RESTING_HEART_RATE", + "value": 57, + "unit": "bpm", + "measuredAt": "2026-07-01T09:00:00.000Z", + "fieldTag": "1970-01-01T00:00:00.000Z:rhr" + } + ] + }, + { + "index": 5, + "key": "dailyRestingHeartRate", + "tz": "Europe/Berlin", + "output": [ + { + "type": "RESTING_HEART_RATE", + "value": 57, + "unit": "bpm", + "measuredAt": "2026-07-01T09:00:00.000Z", + "fieldTag": "1970-01-01T00:00:00.000Z:rhr" + } + ] + }, + { + "index": 5, + "key": "dailyRestingHeartRate", + "tz": "America/New_York", + "output": [ + { + "type": "RESTING_HEART_RATE", + "value": 57, + "unit": "bpm", + "measuredAt": "2026-07-01T09:00:00.000Z", + "fieldTag": "1970-01-01T00:00:00.000Z:rhr" + } + ] + }, + { + "index": 6, + "key": "dailyRespiratoryRate", + "tz": null, + "output": [ + { + "type": "RESPIRATORY_RATE", + "value": 14.8, + "unit": "breaths/min", + "measuredAt": "2026-03-29T12:00:00.000Z", + "fieldTag": "2026-03-29:resp_rate" + } + ] + }, + { + "index": 6, + "key": "dailyRespiratoryRate", + "tz": "Europe/Berlin", + "output": [ + { + "type": "RESPIRATORY_RATE", + "value": 14.8, + "unit": "breaths/min", + "measuredAt": "2026-03-29T10:00:00.000Z", + "fieldTag": "2026-03-29:resp_rate" + } + ] + }, + { + "index": 6, + "key": "dailyRespiratoryRate", + "tz": "America/New_York", + "output": [ + { + "type": "RESPIRATORY_RATE", + "value": 14.8, + "unit": "breaths/min", + "measuredAt": "2026-03-29T16:00:00.000Z", + "fieldTag": "2026-03-29:resp_rate" + } + ] + }, + { "index": 7, "key": "dailyRespiratoryRate", "tz": null, "output": [] }, + { + "index": 7, + "key": "dailyRespiratoryRate", + "tz": "Europe/Berlin", + "output": [] + }, + { + "index": 7, + "key": "dailyRespiratoryRate", + "tz": "America/New_York", + "output": [] + }, + { + "index": 8, + "key": "dailySleepTemperatureDerivations", + "tz": null, + "output": [ + { + "type": "WRIST_TEMPERATURE", + "value": 34.42, + "unit": "celsius", + "measuredAt": "2026-06-05T12:00:00.000Z", + "fieldTag": "2026-06-05:wrist_temp" + } + ] + }, + { + "index": 8, + "key": "dailySleepTemperatureDerivations", + "tz": "Europe/Berlin", + "output": [ + { + "type": "WRIST_TEMPERATURE", + "value": 34.42, + "unit": "celsius", + "measuredAt": "2026-06-05T10:00:00.000Z", + "fieldTag": "2026-06-05:wrist_temp" + } + ] + }, + { + "index": 8, + "key": "dailySleepTemperatureDerivations", + "tz": "America/New_York", + "output": [ + { + "type": "WRIST_TEMPERATURE", + "value": 34.42, + "unit": "celsius", + "measuredAt": "2026-06-05T16:00:00.000Z", + "fieldTag": "2026-06-05:wrist_temp" + } + ] + }, + { + "index": 9, + "key": "dailySleepTemperatureDerivations", + "tz": null, + "output": [ + { + "type": "WRIST_TEMPERATURE", + "value": 34.6, + "unit": "celsius", + "measuredAt": "2026-07-01T09:00:00.000Z", + "fieldTag": "1970-01-01T00:00:00.000Z:wrist_temp" + } + ] + }, + { + "index": 9, + "key": "dailySleepTemperatureDerivations", + "tz": "Europe/Berlin", + "output": [ + { + "type": "WRIST_TEMPERATURE", + "value": 34.6, + "unit": "celsius", + "measuredAt": "2026-07-01T09:00:00.000Z", + "fieldTag": "1970-01-01T00:00:00.000Z:wrist_temp" + } + ] + }, + { + "index": 9, + "key": "dailySleepTemperatureDerivations", + "tz": "America/New_York", + "output": [ + { + "type": "WRIST_TEMPERATURE", + "value": 34.6, + "unit": "celsius", + "measuredAt": "2026-07-01T09:00:00.000Z", + "fieldTag": "1970-01-01T00:00:00.000Z:wrist_temp" + } + ] + }, + { + "index": 10, + "key": "dailyVo2Max", + "tz": null, + "output": [ + { + "type": "VO2_MAX", + "value": 47.25, + "unit": "mL/(kg·min)", + "measuredAt": "2026-06-06T12:00:00.000Z", + "fieldTag": "vo2_max:2026-06-06", + "cumulativeDaily": true + } + ] + }, + { + "index": 10, + "key": "dailyVo2Max", + "tz": "Europe/Berlin", + "output": [ + { + "type": "VO2_MAX", + "value": 47.25, + "unit": "mL/(kg·min)", + "measuredAt": "2026-06-06T10:00:00.000Z", + "fieldTag": "vo2_max:2026-06-06", + "cumulativeDaily": true + } + ] + }, + { + "index": 10, + "key": "dailyVo2Max", + "tz": "America/New_York", + "output": [ + { + "type": "VO2_MAX", + "value": 47.25, + "unit": "mL/(kg·min)", + "measuredAt": "2026-06-06T16:00:00.000Z", + "fieldTag": "vo2_max:2026-06-06", + "cumulativeDaily": true + } + ] + }, + { + "index": 11, + "key": "dailyVo2Max", + "tz": null, + "output": [ + { + "type": "VO2_MAX", + "value": 45, + "unit": "mL/(kg·min)", + "measuredAt": "2026-07-01T09:00:00.000Z", + "fieldTag": "vo2_max:1970-01-01T00:00:00.000Z", + "cumulativeDaily": true + } + ] + }, + { + "index": 11, + "key": "dailyVo2Max", + "tz": "Europe/Berlin", + "output": [ + { + "type": "VO2_MAX", + "value": 45, + "unit": "mL/(kg·min)", + "measuredAt": "2026-07-01T09:00:00.000Z", + "fieldTag": "vo2_max:1970-01-01T00:00:00.000Z", + "cumulativeDaily": true + } + ] + }, + { + "index": 11, + "key": "dailyVo2Max", + "tz": "America/New_York", + "output": [ + { + "type": "VO2_MAX", + "value": 45, + "unit": "mL/(kg·min)", + "measuredAt": "2026-07-01T09:00:00.000Z", + "fieldTag": "vo2_max:1970-01-01T00:00:00.000Z", + "cumulativeDaily": true + } + ] + }, + { "index": 12, "key": "dailyVo2Max", "tz": null, "output": [] }, + { "index": 12, "key": "dailyVo2Max", "tz": "Europe/Berlin", "output": [] }, + { "index": 12, "key": "dailyVo2Max", "tz": "America/New_York", "output": [] } +] diff --git a/src/lib/google-health/__tests__/__golden__/map-exercise.json b/src/lib/google-health/__tests__/__golden__/map-exercise.json new file mode 100644 index 000000000..f1ea990c9 --- /dev/null +++ b/src/lib/google-health/__tests__/__golden__/map-exercise.json @@ -0,0 +1,314 @@ +[ + { + "index": 0, + "tz": null, + "workout": { + "externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0001", + "sportType": "running", + "sportTypeRaw": "RUNNING", + "startedAt": "2026-06-02T17:00:00.000Z", + "endedAt": "2026-06-02T17:42:30.000Z", + "durationSec": 2550, + "totalEnergyKcal": 413, + "totalDistanceM": 7012.35, + "avgHeartRate": 151, + "maxHeartRate": null, + "minHeartRate": null + } + }, + { + "index": 0, + "tz": "Europe/Berlin", + "workout": { + "externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0001", + "sportType": "running", + "sportTypeRaw": "RUNNING", + "startedAt": "2026-06-02T17:00:00.000Z", + "endedAt": "2026-06-02T17:42:30.000Z", + "durationSec": 2550, + "totalEnergyKcal": 413, + "totalDistanceM": 7012.35, + "avgHeartRate": 151, + "maxHeartRate": null, + "minHeartRate": null + } + }, + { + "index": 0, + "tz": "America/New_York", + "workout": { + "externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0001", + "sportType": "running", + "sportTypeRaw": "RUNNING", + "startedAt": "2026-06-02T17:00:00.000Z", + "endedAt": "2026-06-02T17:42:30.000Z", + "durationSec": 2550, + "totalEnergyKcal": 413, + "totalDistanceM": 7012.35, + "avgHeartRate": 151, + "maxHeartRate": null, + "minHeartRate": null + } + }, + { + "index": 1, + "tz": null, + "workout": { + "externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0002", + "sportType": "strength", + "sportTypeRaw": "strength training", + "startedAt": "2026-06-03T07:00:00.000Z", + "endedAt": "2026-06-03T08:00:00.000Z", + "durationSec": 3600, + "totalEnergyKcal": 250, + "totalDistanceM": null, + "avgHeartRate": null, + "maxHeartRate": null, + "minHeartRate": null + } + }, + { + "index": 1, + "tz": "Europe/Berlin", + "workout": { + "externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0002", + "sportType": "strength", + "sportTypeRaw": "strength training", + "startedAt": "2026-06-03T05:00:00.000Z", + "endedAt": "2026-06-03T06:00:00.000Z", + "durationSec": 3600, + "totalEnergyKcal": 250, + "totalDistanceM": null, + "avgHeartRate": null, + "maxHeartRate": null, + "minHeartRate": null + } + }, + { + "index": 1, + "tz": "America/New_York", + "workout": { + "externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0002", + "sportType": "strength", + "sportTypeRaw": "strength training", + "startedAt": "2026-06-03T11:00:00.000Z", + "endedAt": "2026-06-03T12:00:00.000Z", + "durationSec": 3600, + "totalEnergyKcal": 250, + "totalDistanceM": null, + "avgHeartRate": null, + "maxHeartRate": null, + "minHeartRate": null + } + }, + { + "index": 2, + "tz": null, + "workout": { + "externalId": "exercise:2026-06-04T04:30:00.000Z", + "sportType": "other", + "sportTypeRaw": "KITESURFING", + "startedAt": "2026-06-04T04:30:00.000Z", + "endedAt": "2026-06-04T05:15:00.000Z", + "durationSec": 2700, + "totalEnergyKcal": null, + "totalDistanceM": null, + "avgHeartRate": 129, + "maxHeartRate": null, + "minHeartRate": null + } + }, + { + "index": 2, + "tz": "Europe/Berlin", + "workout": { + "externalId": "exercise:2026-06-04T04:30:00.000Z", + "sportType": "other", + "sportTypeRaw": "KITESURFING", + "startedAt": "2026-06-04T04:30:00.000Z", + "endedAt": "2026-06-04T05:15:00.000Z", + "durationSec": 2700, + "totalEnergyKcal": null, + "totalDistanceM": null, + "avgHeartRate": 129, + "maxHeartRate": null, + "minHeartRate": null + } + }, + { + "index": 2, + "tz": "America/New_York", + "workout": { + "externalId": "exercise:2026-06-04T04:30:00.000Z", + "sportType": "other", + "sportTypeRaw": "KITESURFING", + "startedAt": "2026-06-04T04:30:00.000Z", + "endedAt": "2026-06-04T05:15:00.000Z", + "durationSec": 2700, + "totalEnergyKcal": null, + "totalDistanceM": null, + "avgHeartRate": 129, + "maxHeartRate": null, + "minHeartRate": null + } + }, + { + "index": 3, + "tz": null, + "workout": { + "externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0004", + "sportType": "other", + "sportTypeRaw": null, + "startedAt": "2026-06-05T12:00:00.000Z", + "endedAt": "2026-06-05T12:30:00.000Z", + "durationSec": 1800, + "totalEnergyKcal": null, + "totalDistanceM": null, + "avgHeartRate": null, + "maxHeartRate": null, + "minHeartRate": null + } + }, + { + "index": 3, + "tz": "Europe/Berlin", + "workout": { + "externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0004", + "sportType": "other", + "sportTypeRaw": null, + "startedAt": "2026-06-05T12:00:00.000Z", + "endedAt": "2026-06-05T12:30:00.000Z", + "durationSec": 1800, + "totalEnergyKcal": null, + "totalDistanceM": null, + "avgHeartRate": null, + "maxHeartRate": null, + "minHeartRate": null + } + }, + { + "index": 3, + "tz": "America/New_York", + "workout": { + "externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0004", + "sportType": "other", + "sportTypeRaw": null, + "startedAt": "2026-06-05T12:00:00.000Z", + "endedAt": "2026-06-05T12:30:00.000Z", + "durationSec": 1800, + "totalEnergyKcal": null, + "totalDistanceM": null, + "avgHeartRate": null, + "maxHeartRate": null, + "minHeartRate": null + } + }, + { "index": 4, "tz": null, "workout": null }, + { "index": 4, "tz": "Europe/Berlin", "workout": null }, + { "index": 4, "tz": "America/New_York", "workout": null }, + { "index": 5, "tz": null, "workout": null }, + { "index": 5, "tz": "Europe/Berlin", "workout": null }, + { "index": 5, "tz": "America/New_York", "workout": null }, + { + "index": 6, + "tz": null, + "workout": { + "externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0007", + "sportType": "cycling", + "sportTypeRaw": "Mountain-Biking", + "startedAt": "2026-06-08T18:00:00.000Z", + "endedAt": "2026-06-08T19:05:00.000Z", + "durationSec": 3900, + "totalEnergyKcal": 0, + "totalDistanceM": 0, + "avgHeartRate": null, + "maxHeartRate": null, + "minHeartRate": null + } + }, + { + "index": 6, + "tz": "Europe/Berlin", + "workout": { + "externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0007", + "sportType": "cycling", + "sportTypeRaw": "Mountain-Biking", + "startedAt": "2026-06-08T18:00:00.000Z", + "endedAt": "2026-06-08T19:05:00.000Z", + "durationSec": 3900, + "totalEnergyKcal": 0, + "totalDistanceM": 0, + "avgHeartRate": null, + "maxHeartRate": null, + "minHeartRate": null + } + }, + { + "index": 6, + "tz": "America/New_York", + "workout": { + "externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0007", + "sportType": "cycling", + "sportTypeRaw": "Mountain-Biking", + "startedAt": "2026-06-08T18:00:00.000Z", + "endedAt": "2026-06-08T19:05:00.000Z", + "durationSec": 3900, + "totalEnergyKcal": 0, + "totalDistanceM": 0, + "avgHeartRate": null, + "maxHeartRate": null, + "minHeartRate": null + } + }, + { + "index": 7, + "tz": null, + "workout": { + "externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0008", + "sportType": "stairClimber", + "sportTypeRaw": "STAIR_CLIMBER", + "startedAt": "2026-06-09T20:00:00.000Z", + "endedAt": "2026-06-09T20:20:00.000Z", + "durationSec": 1200, + "totalEnergyKcal": null, + "totalDistanceM": null, + "avgHeartRate": null, + "maxHeartRate": null, + "minHeartRate": null + } + }, + { + "index": 7, + "tz": "Europe/Berlin", + "workout": { + "externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0008", + "sportType": "stairClimber", + "sportTypeRaw": "STAIR_CLIMBER", + "startedAt": "2026-06-09T20:00:00.000Z", + "endedAt": "2026-06-09T20:20:00.000Z", + "durationSec": 1200, + "totalEnergyKcal": null, + "totalDistanceM": null, + "avgHeartRate": null, + "maxHeartRate": null, + "minHeartRate": null + } + }, + { + "index": 7, + "tz": "America/New_York", + "workout": { + "externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0008", + "sportType": "stairClimber", + "sportTypeRaw": "STAIR_CLIMBER", + "startedAt": "2026-06-09T20:00:00.000Z", + "endedAt": "2026-06-09T20:20:00.000Z", + "durationSec": 1200, + "totalEnergyKcal": null, + "totalDistanceM": null, + "avgHeartRate": null, + "maxHeartRate": null, + "minHeartRate": null + } + } +] diff --git a/src/lib/google-health/__tests__/__golden__/map-labels.json b/src/lib/google-health/__tests__/__golden__/map-labels.json new file mode 100644 index 000000000..aa6967ed7 --- /dev/null +++ b/src/lib/google-health/__tests__/__golden__/map-labels.json @@ -0,0 +1,37 @@ +{ + "sleepStages": [ + { "raw": "AWAKE", "stage": "AWAKE" }, + { "raw": "wake", "stage": "AWAKE" }, + { "raw": "LIGHT", "stage": "CORE" }, + { "raw": " core ", "stage": "CORE" }, + { "raw": "Deep", "stage": "DEEP" }, + { "raw": "REM", "stage": "REM" }, + { "raw": "ASLEEP", "stage": "ASLEEP" }, + { "raw": "RESTLESS", "stage": "AWAKE" }, + { "raw": "in bed", "stage": "IN_BED" }, + { "raw": "IN-BED", "stage": "IN_BED" }, + { "raw": "inbed", "stage": "IN_BED" }, + { "raw": "SLEEP_STAGE_TYPE_UNSPECIFIED", "stage": null }, + { "raw": "", "stage": null }, + { "raw": 42, "stage": null }, + { "raw": null, "stage": null } + ], + "sportTypes": [ + { "raw": "RUNNING", "sportType": "running" }, + { "raw": "TREADMILL_RUNNING", "sportType": "running" }, + { "raw": "STRENGTH_TRAINING", "sportType": "strength" }, + { "raw": "strength training", "sportType": "strength" }, + { "raw": "Mountain-Biking", "sportType": "cycling" }, + { "raw": "STAIR_CLIMBER", "sportType": "stairClimber" }, + { "raw": "STAIRCLIMBER", "sportType": "stairClimber" }, + { "raw": "HIGH INTENSITY INTERVAL TRAINING", "sportType": "hiit" }, + { "raw": "FOOTBALL", "sportType": "soccer" }, + { "raw": "PILATES", "sportType": "mindAndBody" }, + { "raw": "OTHER_WORKOUT", "sportType": "other" }, + { "raw": "KITESURFING", "sportType": "other" }, + { "raw": "", "sportType": "other" }, + { "raw": " ", "sportType": "other" }, + { "raw": null, "sportType": "other" }, + { "raw": 7, "sportType": "other" } + ] +} diff --git a/src/lib/google-health/__tests__/__golden__/map-rollups.json b/src/lib/google-health/__tests__/__golden__/map-rollups.json new file mode 100644 index 000000000..de87e3ddc --- /dev/null +++ b/src/lib/google-health/__tests__/__golden__/map-rollups.json @@ -0,0 +1,294 @@ +[ + { + "index": 0, + "key": "steps", + "tz": null, + "output": [ + { + "type": "ACTIVITY_STEPS", + "value": 8432, + "unit": "steps", + "measuredAt": "2026-06-01T12:00:00.000Z", + "fieldTag": "steps:2026-06-01", + "cumulativeDaily": true + } + ] + }, + { + "index": 0, + "key": "steps", + "tz": "Europe/Berlin", + "output": [ + { + "type": "ACTIVITY_STEPS", + "value": 8432, + "unit": "steps", + "measuredAt": "2026-06-01T10:00:00.000Z", + "fieldTag": "steps:2026-06-01", + "cumulativeDaily": true + } + ] + }, + { + "index": 0, + "key": "steps", + "tz": "America/New_York", + "output": [ + { + "type": "ACTIVITY_STEPS", + "value": 8432, + "unit": "steps", + "measuredAt": "2026-06-01T16:00:00.000Z", + "fieldTag": "steps:2026-06-01", + "cumulativeDaily": true + } + ] + }, + { + "index": 1, + "key": "steps", + "tz": null, + "output": [ + { + "type": "ACTIVITY_STEPS", + "value": 0, + "unit": "steps", + "measuredAt": "2026-06-02T12:00:00.000Z", + "fieldTag": "steps:2026-06-02", + "cumulativeDaily": true + } + ] + }, + { + "index": 1, + "key": "steps", + "tz": "Europe/Berlin", + "output": [ + { + "type": "ACTIVITY_STEPS", + "value": 0, + "unit": "steps", + "measuredAt": "2026-06-02T10:00:00.000Z", + "fieldTag": "steps:2026-06-02", + "cumulativeDaily": true + } + ] + }, + { + "index": 1, + "key": "steps", + "tz": "America/New_York", + "output": [ + { + "type": "ACTIVITY_STEPS", + "value": 0, + "unit": "steps", + "measuredAt": "2026-06-02T16:00:00.000Z", + "fieldTag": "steps:2026-06-02", + "cumulativeDaily": true + } + ] + }, + { + "index": 2, + "key": "steps", + "tz": null, + "output": [ + { + "type": "ACTIVITY_STEPS", + "value": 10250, + "unit": "steps", + "measuredAt": "2026-06-03T12:00:00.000Z", + "fieldTag": "steps:2026-06-03", + "cumulativeDaily": true + } + ] + }, + { + "index": 2, + "key": "steps", + "tz": "Europe/Berlin", + "output": [ + { + "type": "ACTIVITY_STEPS", + "value": 10250, + "unit": "steps", + "measuredAt": "2026-06-03T10:00:00.000Z", + "fieldTag": "steps:2026-06-03", + "cumulativeDaily": true + } + ] + }, + { + "index": 2, + "key": "steps", + "tz": "America/New_York", + "output": [ + { + "type": "ACTIVITY_STEPS", + "value": 10250, + "unit": "steps", + "measuredAt": "2026-06-03T16:00:00.000Z", + "fieldTag": "steps:2026-06-03", + "cumulativeDaily": true + } + ] + }, + { "index": 3, "key": "steps", "tz": null, "output": [] }, + { "index": 3, "key": "steps", "tz": "Europe/Berlin", "output": [] }, + { "index": 3, "key": "steps", "tz": "America/New_York", "output": [] }, + { + "index": 4, + "key": "distance", + "tz": null, + "output": [ + { + "type": "WALKING_RUNNING_DISTANCE", + "value": 6240.51, + "unit": "m", + "measuredAt": "2026-06-01T12:00:00.000Z", + "fieldTag": "distance:2026-06-01", + "cumulativeDaily": true + } + ] + }, + { + "index": 4, + "key": "distance", + "tz": "Europe/Berlin", + "output": [ + { + "type": "WALKING_RUNNING_DISTANCE", + "value": 6240.51, + "unit": "m", + "measuredAt": "2026-06-01T10:00:00.000Z", + "fieldTag": "distance:2026-06-01", + "cumulativeDaily": true + } + ] + }, + { + "index": 4, + "key": "distance", + "tz": "America/New_York", + "output": [ + { + "type": "WALKING_RUNNING_DISTANCE", + "value": 6240.51, + "unit": "m", + "measuredAt": "2026-06-01T16:00:00.000Z", + "fieldTag": "distance:2026-06-01", + "cumulativeDaily": true + } + ] + }, + { "index": 5, "key": "distance", "tz": null, "output": [] }, + { "index": 5, "key": "distance", "tz": "Europe/Berlin", "output": [] }, + { "index": 5, "key": "distance", "tz": "America/New_York", "output": [] }, + { + "index": 6, + "key": "activeEnergyBurned", + "tz": null, + "output": [ + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 512.75, + "unit": "kcal", + "measuredAt": "2026-06-01T12:00:00.000Z", + "fieldTag": "active_energy:2026-06-01", + "cumulativeDaily": true + } + ] + }, + { + "index": 6, + "key": "activeEnergyBurned", + "tz": "Europe/Berlin", + "output": [ + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 512.75, + "unit": "kcal", + "measuredAt": "2026-06-01T10:00:00.000Z", + "fieldTag": "active_energy:2026-06-01", + "cumulativeDaily": true + } + ] + }, + { + "index": 6, + "key": "activeEnergyBurned", + "tz": "America/New_York", + "output": [ + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 512.75, + "unit": "kcal", + "measuredAt": "2026-06-01T16:00:00.000Z", + "fieldTag": "active_energy:2026-06-01", + "cumulativeDaily": true + } + ] + }, + { "index": 7, "key": "activeEnergyBurned", "tz": null, "output": [] }, + { + "index": 7, + "key": "activeEnergyBurned", + "tz": "Europe/Berlin", + "output": [] + }, + { + "index": 7, + "key": "activeEnergyBurned", + "tz": "America/New_York", + "output": [] + }, + { + "index": 8, + "key": "floors", + "tz": null, + "output": [ + { + "type": "FLIGHTS_CLIMBED", + "value": 12, + "unit": "flights", + "measuredAt": "2026-12-31T12:00:00.000Z", + "fieldTag": "floors:2026-12-31", + "cumulativeDaily": true + } + ] + }, + { + "index": 8, + "key": "floors", + "tz": "Europe/Berlin", + "output": [ + { + "type": "FLIGHTS_CLIMBED", + "value": 12, + "unit": "flights", + "measuredAt": "2026-12-31T11:00:00.000Z", + "fieldTag": "floors:2026-12-31", + "cumulativeDaily": true + } + ] + }, + { + "index": 8, + "key": "floors", + "tz": "America/New_York", + "output": [ + { + "type": "FLIGHTS_CLIMBED", + "value": 12, + "unit": "flights", + "measuredAt": "2026-12-31T17:00:00.000Z", + "fieldTag": "floors:2026-12-31", + "cumulativeDaily": true + } + ] + }, + { "index": 9, "key": "floors", "tz": null, "output": [] }, + { "index": 9, "key": "floors", "tz": "Europe/Berlin", "output": [] }, + { "index": 9, "key": "floors", "tz": "America/New_York", "output": [] } +] diff --git a/src/lib/google-health/__tests__/__golden__/map-samples.json b/src/lib/google-health/__tests__/__golden__/map-samples.json new file mode 100644 index 000000000..a21d8c033 --- /dev/null +++ b/src/lib/google-health/__tests__/__golden__/map-samples.json @@ -0,0 +1,693 @@ +{ + "measurements": [ + { + "index": 0, + "key": "weight", + "tz": null, + "output": [ + { + "type": "WEIGHT", + "value": 78.4, + "unit": "kg", + "measuredAt": "2026-06-02T06:15:00.000Z", + "fieldTag": "2026-06-02T06:15:00.000Z:weight" + } + ] + }, + { + "index": 0, + "key": "weight", + "tz": "Europe/Berlin", + "output": [ + { + "type": "WEIGHT", + "value": 78.4, + "unit": "kg", + "measuredAt": "2026-06-02T06:15:00.000Z", + "fieldTag": "2026-06-02T06:15:00.000Z:weight" + } + ] + }, + { + "index": 0, + "key": "weight", + "tz": "America/New_York", + "output": [ + { + "type": "WEIGHT", + "value": 78.4, + "unit": "kg", + "measuredAt": "2026-06-02T06:15:00.000Z", + "fieldTag": "2026-06-02T06:15:00.000Z:weight" + } + ] + }, + { + "index": 1, + "key": "weight", + "tz": null, + "output": [ + { + "type": "WEIGHT", + "value": 78.25, + "unit": "kg", + "measuredAt": "2026-06-03T05:30:00.000Z", + "fieldTag": "2026-06-03T05:30:00.000Z:weight" + } + ] + }, + { + "index": 1, + "key": "weight", + "tz": "Europe/Berlin", + "output": [ + { + "type": "WEIGHT", + "value": 78.25, + "unit": "kg", + "measuredAt": "2026-06-03T05:30:00.000Z", + "fieldTag": "2026-06-03T05:30:00.000Z:weight" + } + ] + }, + { + "index": 1, + "key": "weight", + "tz": "America/New_York", + "output": [ + { + "type": "WEIGHT", + "value": 78.25, + "unit": "kg", + "measuredAt": "2026-06-03T05:30:00.000Z", + "fieldTag": "2026-06-03T05:30:00.000Z:weight" + } + ] + }, + { "index": 2, "key": "weight", "tz": null, "output": [] }, + { "index": 2, "key": "weight", "tz": "Europe/Berlin", "output": [] }, + { "index": 2, "key": "weight", "tz": "America/New_York", "output": [] }, + { + "index": 3, + "key": "weight", + "tz": null, + "output": [ + { + "type": "WEIGHT", + "value": 77.9, + "unit": "kg", + "measuredAt": "2026-06-04T23:45:00.000Z", + "fieldTag": "2026-06-04T23:45:00.000Z:weight" + } + ] + }, + { + "index": 3, + "key": "weight", + "tz": "Europe/Berlin", + "output": [ + { + "type": "WEIGHT", + "value": 77.9, + "unit": "kg", + "measuredAt": "2026-06-04T21:45:00.000Z", + "fieldTag": "2026-06-04T23:45:00.000Z:weight" + } + ] + }, + { + "index": 3, + "key": "weight", + "tz": "America/New_York", + "output": [ + { + "type": "WEIGHT", + "value": 77.9, + "unit": "kg", + "measuredAt": "2026-06-05T03:45:00.000Z", + "fieldTag": "2026-06-04T23:45:00.000Z:weight" + } + ] + }, + { + "index": 4, + "key": "bodyFat", + "tz": null, + "output": [ + { + "type": "BODY_FAT", + "value": 21.4, + "unit": "%", + "measuredAt": "2026-06-02T06:15:00.000Z", + "fieldTag": "2026-06-02T06:15:00.000Z:body_fat" + } + ] + }, + { + "index": 4, + "key": "bodyFat", + "tz": "Europe/Berlin", + "output": [ + { + "type": "BODY_FAT", + "value": 21.4, + "unit": "%", + "measuredAt": "2026-06-02T06:15:00.000Z", + "fieldTag": "2026-06-02T06:15:00.000Z:body_fat" + } + ] + }, + { + "index": 4, + "key": "bodyFat", + "tz": "America/New_York", + "output": [ + { + "type": "BODY_FAT", + "value": 21.4, + "unit": "%", + "measuredAt": "2026-06-02T06:15:00.000Z", + "fieldTag": "2026-06-02T06:15:00.000Z:body_fat" + } + ] + }, + { + "index": 5, + "key": "bodyFat", + "tz": null, + "output": [ + { + "type": "BODY_FAT", + "value": 20.9, + "unit": "%", + "measuredAt": "2026-07-01T09:00:00.000Z", + "fieldTag": "1970-01-01T00:00:00.000Z:body_fat" + } + ] + }, + { + "index": 5, + "key": "bodyFat", + "tz": "Europe/Berlin", + "output": [ + { + "type": "BODY_FAT", + "value": 20.9, + "unit": "%", + "measuredAt": "2026-07-01T09:00:00.000Z", + "fieldTag": "1970-01-01T00:00:00.000Z:body_fat" + } + ] + }, + { + "index": 5, + "key": "bodyFat", + "tz": "America/New_York", + "output": [ + { + "type": "BODY_FAT", + "value": 20.9, + "unit": "%", + "measuredAt": "2026-07-01T09:00:00.000Z", + "fieldTag": "1970-01-01T00:00:00.000Z:body_fat" + } + ] + }, + { + "index": 6, + "key": "heartRate", + "tz": null, + "output": [ + { + "type": "PULSE", + "value": 64, + "unit": "bpm", + "measuredAt": "2026-06-02T16:00:30.000Z", + "fieldTag": "2026-06-02T16:00:30.000Z:hr" + } + ] + }, + { + "index": 6, + "key": "heartRate", + "tz": "Europe/Berlin", + "output": [ + { + "type": "PULSE", + "value": 64, + "unit": "bpm", + "measuredAt": "2026-06-02T16:00:30.000Z", + "fieldTag": "2026-06-02T16:00:30.000Z:hr" + } + ] + }, + { + "index": 6, + "key": "heartRate", + "tz": "America/New_York", + "output": [ + { + "type": "PULSE", + "value": 64, + "unit": "bpm", + "measuredAt": "2026-06-02T16:00:30.000Z", + "fieldTag": "2026-06-02T16:00:30.000Z:hr" + } + ] + }, + { "index": 7, "key": "heartRate", "tz": null, "output": [] }, + { "index": 7, "key": "heartRate", "tz": "Europe/Berlin", "output": [] }, + { "index": 7, "key": "heartRate", "tz": "America/New_York", "output": [] }, + { + "index": 8, + "key": "heartRate", + "tz": null, + "output": [ + { + "type": "PULSE", + "value": 58, + "unit": "bpm", + "measuredAt": "2026-07-01T09:00:00.000Z", + "fieldTag": "1970-01-01T00:00:00.000Z:hr" + } + ] + }, + { + "index": 8, + "key": "heartRate", + "tz": "Europe/Berlin", + "output": [ + { + "type": "PULSE", + "value": 58, + "unit": "bpm", + "measuredAt": "2026-07-01T09:00:00.000Z", + "fieldTag": "1970-01-01T00:00:00.000Z:hr" + } + ] + }, + { + "index": 8, + "key": "heartRate", + "tz": "America/New_York", + "output": [ + { + "type": "PULSE", + "value": 58, + "unit": "bpm", + "measuredAt": "2026-07-01T09:00:00.000Z", + "fieldTag": "1970-01-01T00:00:00.000Z:hr" + } + ] + }, + { + "index": 9, + "key": "bloodGlucose", + "tz": null, + "output": [ + { + "type": "BLOOD_GLUCOSE", + "value": 96.33, + "unit": "mg/dL", + "measuredAt": "2026-06-05T08:00:00.000Z", + "fieldTag": "2026-06-05T08:00:00.000Z:glucose" + } + ] + }, + { + "index": 9, + "key": "bloodGlucose", + "tz": "Europe/Berlin", + "output": [ + { + "type": "BLOOD_GLUCOSE", + "value": 96.33, + "unit": "mg/dL", + "measuredAt": "2026-06-05T08:00:00.000Z", + "fieldTag": "2026-06-05T08:00:00.000Z:glucose" + } + ] + }, + { + "index": 9, + "key": "bloodGlucose", + "tz": "America/New_York", + "output": [ + { + "type": "BLOOD_GLUCOSE", + "value": 96.33, + "unit": "mg/dL", + "measuredAt": "2026-06-05T08:00:00.000Z", + "fieldTag": "2026-06-05T08:00:00.000Z:glucose" + } + ] + }, + { "index": 10, "key": "bloodGlucose", "tz": null, "output": [] }, + { "index": 10, "key": "bloodGlucose", "tz": "Europe/Berlin", "output": [] }, + { + "index": 10, + "key": "bloodGlucose", + "tz": "America/New_York", + "output": [] + }, + { + "index": 11, + "key": "coreBodyTemperature", + "tz": null, + "output": [ + { + "type": "BODY_TEMPERATURE", + "value": 36.75, + "unit": "celsius", + "measuredAt": "2026-06-06T03:00:00.000Z", + "fieldTag": "2026-06-06T03:00:00.000Z:core_temp" + } + ] + }, + { + "index": 11, + "key": "coreBodyTemperature", + "tz": "Europe/Berlin", + "output": [ + { + "type": "BODY_TEMPERATURE", + "value": 36.75, + "unit": "celsius", + "measuredAt": "2026-06-06T01:00:00.000Z", + "fieldTag": "2026-06-06T03:00:00.000Z:core_temp" + } + ] + }, + { + "index": 11, + "key": "coreBodyTemperature", + "tz": "America/New_York", + "output": [ + { + "type": "BODY_TEMPERATURE", + "value": 36.75, + "unit": "celsius", + "measuredAt": "2026-06-06T07:00:00.000Z", + "fieldTag": "2026-06-06T03:00:00.000Z:core_temp" + } + ] + }, + { + "index": 12, + "key": "height", + "tz": null, + "output": { "cm": 180.5, "sampledAt": "2026-01-15T10:00:00.000Z" } + }, + { + "index": 12, + "key": "height", + "tz": "Europe/Berlin", + "output": { "cm": 180.5, "sampledAt": "2026-01-15T10:00:00.000Z" } + }, + { + "index": 12, + "key": "height", + "tz": "America/New_York", + "output": { "cm": 180.5, "sampledAt": "2026-01-15T10:00:00.000Z" } + }, + { + "index": 13, + "key": "height", + "tz": null, + "output": { "cm": 179.8, "sampledAt": null } + }, + { + "index": 13, + "key": "height", + "tz": "Europe/Berlin", + "output": { "cm": 179.8, "sampledAt": null } + }, + { + "index": 13, + "key": "height", + "tz": "America/New_York", + "output": { "cm": 179.8, "sampledAt": null } + }, + { "index": 14, "key": "height", "tz": null, "output": null }, + { "index": 14, "key": "height", "tz": "Europe/Berlin", "output": null }, + { "index": 14, "key": "height", "tz": "America/New_York", "output": null } + ], + "sampleInstants": [ + { + "index": 0, + "dataType": "weight", + "tz": null, + "instant": "2026-06-02T06:15:00.000Z" + }, + { + "index": 0, + "dataType": "weight", + "tz": "Europe/Berlin", + "instant": "2026-06-02T06:15:00.000Z" + }, + { + "index": 0, + "dataType": "weight", + "tz": "America/New_York", + "instant": "2026-06-02T06:15:00.000Z" + }, + { + "index": 1, + "dataType": "weight", + "tz": null, + "instant": "2026-06-03T05:30:00.000Z" + }, + { + "index": 1, + "dataType": "weight", + "tz": "Europe/Berlin", + "instant": "2026-06-03T05:30:00.000Z" + }, + { + "index": 1, + "dataType": "weight", + "tz": "America/New_York", + "instant": "2026-06-03T05:30:00.000Z" + }, + { + "index": 2, + "dataType": "weight", + "tz": null, + "instant": "2026-06-04T23:45:00.000Z" + }, + { + "index": 2, + "dataType": "weight", + "tz": "Europe/Berlin", + "instant": "2026-06-04T21:45:00.000Z" + }, + { + "index": 2, + "dataType": "weight", + "tz": "America/New_York", + "instant": "2026-06-05T03:45:00.000Z" + }, + { + "index": 3, + "dataType": "weight", + "tz": null, + "instant": "2026-06-04T23:45:00.000Z" + }, + { + "index": 3, + "dataType": "weight", + "tz": "Europe/Berlin", + "instant": "2026-06-04T21:45:00.000Z" + }, + { + "index": 3, + "dataType": "weight", + "tz": "America/New_York", + "instant": "2026-06-05T03:45:00.000Z" + }, + { + "index": 4, + "dataType": "bodyFat", + "tz": null, + "instant": "2026-06-02T06:15:00.000Z" + }, + { + "index": 4, + "dataType": "bodyFat", + "tz": "Europe/Berlin", + "instant": "2026-06-02T06:15:00.000Z" + }, + { + "index": 4, + "dataType": "bodyFat", + "tz": "America/New_York", + "instant": "2026-06-02T06:15:00.000Z" + }, + { "index": 5, "dataType": "bodyFat", "tz": null, "instant": null }, + { + "index": 5, + "dataType": "bodyFat", + "tz": "Europe/Berlin", + "instant": null + }, + { + "index": 5, + "dataType": "bodyFat", + "tz": "America/New_York", + "instant": null + }, + { + "index": 6, + "dataType": "heartRate", + "tz": null, + "instant": "2026-06-02T16:00:30.000Z" + }, + { + "index": 6, + "dataType": "heartRate", + "tz": "Europe/Berlin", + "instant": "2026-06-02T16:00:30.000Z" + }, + { + "index": 6, + "dataType": "heartRate", + "tz": "America/New_York", + "instant": "2026-06-02T16:00:30.000Z" + }, + { + "index": 7, + "dataType": "heartRate", + "tz": null, + "instant": "2026-06-02T00:10:00.000Z" + }, + { + "index": 7, + "dataType": "heartRate", + "tz": "Europe/Berlin", + "instant": "2026-06-01T22:10:00.000Z" + }, + { + "index": 7, + "dataType": "heartRate", + "tz": "America/New_York", + "instant": "2026-06-02T04:10:00.000Z" + }, + { "index": 8, "dataType": "heartRate", "tz": null, "instant": null }, + { + "index": 8, + "dataType": "heartRate", + "tz": "Europe/Berlin", + "instant": null + }, + { + "index": 8, + "dataType": "heartRate", + "tz": "America/New_York", + "instant": null + }, + { + "index": 9, + "dataType": "bloodGlucose", + "tz": null, + "instant": "2026-06-05T08:00:00.000Z" + }, + { + "index": 9, + "dataType": "bloodGlucose", + "tz": "Europe/Berlin", + "instant": "2026-06-05T08:00:00.000Z" + }, + { + "index": 9, + "dataType": "bloodGlucose", + "tz": "America/New_York", + "instant": "2026-06-05T08:00:00.000Z" + }, + { + "index": 10, + "dataType": "bloodGlucose", + "tz": null, + "instant": "2026-06-05T09:00:00.000Z" + }, + { + "index": 10, + "dataType": "bloodGlucose", + "tz": "Europe/Berlin", + "instant": "2026-06-05T09:00:00.000Z" + }, + { + "index": 10, + "dataType": "bloodGlucose", + "tz": "America/New_York", + "instant": "2026-06-05T09:00:00.000Z" + }, + { + "index": 11, + "dataType": "coreBodyTemperature", + "tz": null, + "instant": "2026-06-06T03:00:00.000Z" + }, + { + "index": 11, + "dataType": "coreBodyTemperature", + "tz": "Europe/Berlin", + "instant": "2026-06-06T01:00:00.000Z" + }, + { + "index": 11, + "dataType": "coreBodyTemperature", + "tz": "America/New_York", + "instant": "2026-06-06T07:00:00.000Z" + }, + { + "index": 12, + "dataType": "height", + "tz": null, + "instant": "2026-01-15T10:00:00.000Z" + }, + { + "index": 12, + "dataType": "height", + "tz": "Europe/Berlin", + "instant": "2026-01-15T10:00:00.000Z" + }, + { + "index": 12, + "dataType": "height", + "tz": "America/New_York", + "instant": "2026-01-15T10:00:00.000Z" + }, + { "index": 13, "dataType": "height", "tz": null, "instant": null }, + { + "index": 13, + "dataType": "height", + "tz": "Europe/Berlin", + "instant": null + }, + { + "index": 13, + "dataType": "height", + "tz": "America/New_York", + "instant": null + }, + { + "index": 14, + "dataType": "height", + "tz": null, + "instant": "2026-01-16T10:00:00.000Z" + }, + { + "index": 14, + "dataType": "height", + "tz": "Europe/Berlin", + "instant": "2026-01-16T10:00:00.000Z" + }, + { + "index": 14, + "dataType": "height", + "tz": "America/New_York", + "instant": "2026-01-16T10:00:00.000Z" + } + ], + "sampleInstantOfDailyType": null +} diff --git a/src/lib/google-health/__tests__/__golden__/map-sleep.json b/src/lib/google-health/__tests__/__golden__/map-sleep.json new file mode 100644 index 000000000..a7ab91f8a --- /dev/null +++ b/src/lib/google-health/__tests__/__golden__/map-sleep.json @@ -0,0 +1,758 @@ +[ + { + "index": 0, + "tz": null, + "detailed": { + "anchor": "users/me/dataTypes/sleep/dataPoints/sleep-0001", + "windowStart": "2026-06-01T22:30:00.000Z", + "windowEnd": "2026-06-02T06:10:00.000Z", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 15, + "unit": "minutes", + "measuredAt": "2026-06-01T22:45:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-01T22:30:00.000Z", + "sleepStage": "AWAKE" + }, + { + "type": "SLEEP_DURATION", + "value": 90, + "unit": "minutes", + "measuredAt": "2026-06-02T00:15:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-01T22:45:00.000Z", + "sleepStage": "CORE" + }, + { + "type": "SLEEP_DURATION", + "value": 65.5, + "unit": "minutes", + "measuredAt": "2026-06-02T01:20:30.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T00:15:00.000Z", + "sleepStage": "DEEP" + }, + { + "type": "SLEEP_DURATION", + "value": 44.5, + "unit": "minutes", + "measuredAt": "2026-06-02T02:05:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T01:20:30.000Z", + "sleepStage": "REM" + }, + { + "type": "SLEEP_DURATION", + "value": 245, + "unit": "minutes", + "measuredAt": "2026-06-02T06:10:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T02:05:00.000Z", + "sleepStage": "CORE" + } + ], + "unknownStageSegments": 0, + "unknownStageLabels": [] + }, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 15, + "unit": "minutes", + "measuredAt": "2026-06-01T22:45:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-01T22:30:00.000Z", + "sleepStage": "AWAKE" + }, + { + "type": "SLEEP_DURATION", + "value": 90, + "unit": "minutes", + "measuredAt": "2026-06-02T00:15:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-01T22:45:00.000Z", + "sleepStage": "CORE" + }, + { + "type": "SLEEP_DURATION", + "value": 65.5, + "unit": "minutes", + "measuredAt": "2026-06-02T01:20:30.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T00:15:00.000Z", + "sleepStage": "DEEP" + }, + { + "type": "SLEEP_DURATION", + "value": 44.5, + "unit": "minutes", + "measuredAt": "2026-06-02T02:05:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T01:20:30.000Z", + "sleepStage": "REM" + }, + { + "type": "SLEEP_DURATION", + "value": 245, + "unit": "minutes", + "measuredAt": "2026-06-02T06:10:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T02:05:00.000Z", + "sleepStage": "CORE" + } + ] + }, + { + "index": 0, + "tz": "Europe/Berlin", + "detailed": { + "anchor": "users/me/dataTypes/sleep/dataPoints/sleep-0001", + "windowStart": "2026-06-01T22:30:00.000Z", + "windowEnd": "2026-06-02T06:10:00.000Z", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 15, + "unit": "minutes", + "measuredAt": "2026-06-01T22:45:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-01T22:30:00.000Z", + "sleepStage": "AWAKE" + }, + { + "type": "SLEEP_DURATION", + "value": 90, + "unit": "minutes", + "measuredAt": "2026-06-02T00:15:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-01T22:45:00.000Z", + "sleepStage": "CORE" + }, + { + "type": "SLEEP_DURATION", + "value": 65.5, + "unit": "minutes", + "measuredAt": "2026-06-02T01:20:30.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T00:15:00.000Z", + "sleepStage": "DEEP" + }, + { + "type": "SLEEP_DURATION", + "value": 44.5, + "unit": "minutes", + "measuredAt": "2026-06-02T02:05:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T01:20:30.000Z", + "sleepStage": "REM" + }, + { + "type": "SLEEP_DURATION", + "value": 245, + "unit": "minutes", + "measuredAt": "2026-06-02T06:10:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T02:05:00.000Z", + "sleepStage": "CORE" + } + ], + "unknownStageSegments": 0, + "unknownStageLabels": [] + }, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 15, + "unit": "minutes", + "measuredAt": "2026-06-01T22:45:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-01T22:30:00.000Z", + "sleepStage": "AWAKE" + }, + { + "type": "SLEEP_DURATION", + "value": 90, + "unit": "minutes", + "measuredAt": "2026-06-02T00:15:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-01T22:45:00.000Z", + "sleepStage": "CORE" + }, + { + "type": "SLEEP_DURATION", + "value": 65.5, + "unit": "minutes", + "measuredAt": "2026-06-02T01:20:30.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T00:15:00.000Z", + "sleepStage": "DEEP" + }, + { + "type": "SLEEP_DURATION", + "value": 44.5, + "unit": "minutes", + "measuredAt": "2026-06-02T02:05:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T01:20:30.000Z", + "sleepStage": "REM" + }, + { + "type": "SLEEP_DURATION", + "value": 245, + "unit": "minutes", + "measuredAt": "2026-06-02T06:10:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T02:05:00.000Z", + "sleepStage": "CORE" + } + ] + }, + { + "index": 0, + "tz": "America/New_York", + "detailed": { + "anchor": "users/me/dataTypes/sleep/dataPoints/sleep-0001", + "windowStart": "2026-06-01T22:30:00.000Z", + "windowEnd": "2026-06-02T06:10:00.000Z", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 15, + "unit": "minutes", + "measuredAt": "2026-06-01T22:45:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-01T22:30:00.000Z", + "sleepStage": "AWAKE" + }, + { + "type": "SLEEP_DURATION", + "value": 90, + "unit": "minutes", + "measuredAt": "2026-06-02T00:15:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-01T22:45:00.000Z", + "sleepStage": "CORE" + }, + { + "type": "SLEEP_DURATION", + "value": 65.5, + "unit": "minutes", + "measuredAt": "2026-06-02T01:20:30.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T00:15:00.000Z", + "sleepStage": "DEEP" + }, + { + "type": "SLEEP_DURATION", + "value": 44.5, + "unit": "minutes", + "measuredAt": "2026-06-02T02:05:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T01:20:30.000Z", + "sleepStage": "REM" + }, + { + "type": "SLEEP_DURATION", + "value": 245, + "unit": "minutes", + "measuredAt": "2026-06-02T06:10:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T02:05:00.000Z", + "sleepStage": "CORE" + } + ], + "unknownStageSegments": 0, + "unknownStageLabels": [] + }, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 15, + "unit": "minutes", + "measuredAt": "2026-06-01T22:45:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-01T22:30:00.000Z", + "sleepStage": "AWAKE" + }, + { + "type": "SLEEP_DURATION", + "value": 90, + "unit": "minutes", + "measuredAt": "2026-06-02T00:15:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-01T22:45:00.000Z", + "sleepStage": "CORE" + }, + { + "type": "SLEEP_DURATION", + "value": 65.5, + "unit": "minutes", + "measuredAt": "2026-06-02T01:20:30.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T00:15:00.000Z", + "sleepStage": "DEEP" + }, + { + "type": "SLEEP_DURATION", + "value": 44.5, + "unit": "minutes", + "measuredAt": "2026-06-02T02:05:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T01:20:30.000Z", + "sleepStage": "REM" + }, + { + "type": "SLEEP_DURATION", + "value": 245, + "unit": "minutes", + "measuredAt": "2026-06-02T06:10:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T02:05:00.000Z", + "sleepStage": "CORE" + } + ] + }, + { + "index": 1, + "tz": null, + "detailed": { + "anchor": "users/me/dataTypes/sleep/dataPoints/sleep-0002", + "windowStart": "2026-06-02T23:50:00.000Z", + "windowEnd": "2026-06-03T07:00:00.000Z", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 30, + "unit": "minutes", + "measuredAt": "2026-06-03T00:20:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-02T23:50:00.000Z", + "sleepStage": "ASLEEP" + }, + { + "type": "SLEEP_DURATION", + "value": 5, + "unit": "minutes", + "measuredAt": "2026-06-03T00:25:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-03T00:20:00.000Z", + "sleepStage": "AWAKE" + }, + { + "type": "SLEEP_DURATION", + "value": 395, + "unit": "minutes", + "measuredAt": "2026-06-03T07:00:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-03T00:25:00.000Z", + "sleepStage": "ASLEEP" + } + ], + "unknownStageSegments": 0, + "unknownStageLabels": [] + }, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 30, + "unit": "minutes", + "measuredAt": "2026-06-03T00:20:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-02T23:50:00.000Z", + "sleepStage": "ASLEEP" + }, + { + "type": "SLEEP_DURATION", + "value": 5, + "unit": "minutes", + "measuredAt": "2026-06-03T00:25:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-03T00:20:00.000Z", + "sleepStage": "AWAKE" + }, + { + "type": "SLEEP_DURATION", + "value": 395, + "unit": "minutes", + "measuredAt": "2026-06-03T07:00:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-03T00:25:00.000Z", + "sleepStage": "ASLEEP" + } + ] + }, + { + "index": 1, + "tz": "Europe/Berlin", + "detailed": { + "anchor": "users/me/dataTypes/sleep/dataPoints/sleep-0002", + "windowStart": "2026-06-02T21:50:00.000Z", + "windowEnd": "2026-06-03T05:00:00.000Z", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 30, + "unit": "minutes", + "measuredAt": "2026-06-02T22:20:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-02T21:50:00.000Z", + "sleepStage": "ASLEEP" + }, + { + "type": "SLEEP_DURATION", + "value": 5, + "unit": "minutes", + "measuredAt": "2026-06-02T22:25:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-02T22:20:00.000Z", + "sleepStage": "AWAKE" + }, + { + "type": "SLEEP_DURATION", + "value": 395, + "unit": "minutes", + "measuredAt": "2026-06-03T05:00:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-02T22:25:00.000Z", + "sleepStage": "ASLEEP" + } + ], + "unknownStageSegments": 0, + "unknownStageLabels": [] + }, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 30, + "unit": "minutes", + "measuredAt": "2026-06-02T22:20:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-02T21:50:00.000Z", + "sleepStage": "ASLEEP" + }, + { + "type": "SLEEP_DURATION", + "value": 5, + "unit": "minutes", + "measuredAt": "2026-06-02T22:25:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-02T22:20:00.000Z", + "sleepStage": "AWAKE" + }, + { + "type": "SLEEP_DURATION", + "value": 395, + "unit": "minutes", + "measuredAt": "2026-06-03T05:00:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-02T22:25:00.000Z", + "sleepStage": "ASLEEP" + } + ] + }, + { + "index": 1, + "tz": "America/New_York", + "detailed": { + "anchor": "users/me/dataTypes/sleep/dataPoints/sleep-0002", + "windowStart": "2026-06-03T03:50:00.000Z", + "windowEnd": "2026-06-03T11:00:00.000Z", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 30, + "unit": "minutes", + "measuredAt": "2026-06-03T04:20:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-03T03:50:00.000Z", + "sleepStage": "ASLEEP" + }, + { + "type": "SLEEP_DURATION", + "value": 5, + "unit": "minutes", + "measuredAt": "2026-06-03T04:25:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-03T04:20:00.000Z", + "sleepStage": "AWAKE" + }, + { + "type": "SLEEP_DURATION", + "value": 395, + "unit": "minutes", + "measuredAt": "2026-06-03T11:00:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-03T04:25:00.000Z", + "sleepStage": "ASLEEP" + } + ], + "unknownStageSegments": 0, + "unknownStageLabels": [] + }, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 30, + "unit": "minutes", + "measuredAt": "2026-06-03T04:20:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-03T03:50:00.000Z", + "sleepStage": "ASLEEP" + }, + { + "type": "SLEEP_DURATION", + "value": 5, + "unit": "minutes", + "measuredAt": "2026-06-03T04:25:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-03T04:20:00.000Z", + "sleepStage": "AWAKE" + }, + { + "type": "SLEEP_DURATION", + "value": 395, + "unit": "minutes", + "measuredAt": "2026-06-03T11:00:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-03T04:25:00.000Z", + "sleepStage": "ASLEEP" + } + ] + }, + { + "index": 2, + "tz": null, + "detailed": { + "anchor": "2026-06-05T03:30:00.000Z", + "windowStart": "2026-06-04T20:00:00.000Z", + "windowEnd": "2026-06-04T23:00:00.000Z", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 180, + "unit": "minutes", + "measuredAt": "2026-06-04T23:00:00.000Z", + "fieldTag": "2026-06-05T03:30:00.000Z:sleep:2026-06-04T20:00:00.000Z", + "sleepStage": "DEEP" + } + ], + "unknownStageSegments": 1, + "unknownStageLabels": ["SLEEP_STAGE_TYPE_UNSPECIFIED"] + }, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 180, + "unit": "minutes", + "measuredAt": "2026-06-04T23:00:00.000Z", + "fieldTag": "2026-06-05T03:30:00.000Z:sleep:2026-06-04T20:00:00.000Z", + "sleepStage": "DEEP" + } + ] + }, + { + "index": 2, + "tz": "Europe/Berlin", + "detailed": { + "anchor": "2026-06-05T03:30:00.000Z", + "windowStart": "2026-06-04T20:00:00.000Z", + "windowEnd": "2026-06-04T23:00:00.000Z", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 180, + "unit": "minutes", + "measuredAt": "2026-06-04T23:00:00.000Z", + "fieldTag": "2026-06-05T03:30:00.000Z:sleep:2026-06-04T20:00:00.000Z", + "sleepStage": "DEEP" + } + ], + "unknownStageSegments": 1, + "unknownStageLabels": ["SLEEP_STAGE_TYPE_UNSPECIFIED"] + }, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 180, + "unit": "minutes", + "measuredAt": "2026-06-04T23:00:00.000Z", + "fieldTag": "2026-06-05T03:30:00.000Z:sleep:2026-06-04T20:00:00.000Z", + "sleepStage": "DEEP" + } + ] + }, + { + "index": 2, + "tz": "America/New_York", + "detailed": { + "anchor": "2026-06-05T03:30:00.000Z", + "windowStart": "2026-06-04T20:00:00.000Z", + "windowEnd": "2026-06-04T23:00:00.000Z", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 180, + "unit": "minutes", + "measuredAt": "2026-06-04T23:00:00.000Z", + "fieldTag": "2026-06-05T03:30:00.000Z:sleep:2026-06-04T20:00:00.000Z", + "sleepStage": "DEEP" + } + ], + "unknownStageSegments": 1, + "unknownStageLabels": ["SLEEP_STAGE_TYPE_UNSPECIFIED"] + }, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 180, + "unit": "minutes", + "measuredAt": "2026-06-04T23:00:00.000Z", + "fieldTag": "2026-06-05T03:30:00.000Z:sleep:2026-06-04T20:00:00.000Z", + "sleepStage": "DEEP" + } + ] + }, + { + "index": 3, + "tz": null, + "detailed": { + "anchor": "2026-06-06T23:00:00.000Z", + "windowStart": null, + "windowEnd": null, + "rows": [], + "unknownStageSegments": 0, + "unknownStageLabels": [] + }, + "rows": [] + }, + { + "index": 3, + "tz": "Europe/Berlin", + "detailed": { + "anchor": "2026-06-06T23:00:00.000Z", + "windowStart": null, + "windowEnd": null, + "rows": [], + "unknownStageSegments": 0, + "unknownStageLabels": [] + }, + "rows": [] + }, + { + "index": 3, + "tz": "America/New_York", + "detailed": { + "anchor": "2026-06-06T23:00:00.000Z", + "windowStart": null, + "windowEnd": null, + "rows": [], + "unknownStageSegments": 0, + "unknownStageLabels": [] + }, + "rows": [] + }, + { + "index": 4, + "tz": null, + "detailed": { + "anchor": "1970-01-01T00:00:00.000Z", + "windowStart": null, + "windowEnd": null, + "rows": [], + "unknownStageSegments": 0, + "unknownStageLabels": [] + }, + "rows": [] + }, + { + "index": 4, + "tz": "Europe/Berlin", + "detailed": { + "anchor": "1970-01-01T00:00:00.000Z", + "windowStart": null, + "windowEnd": null, + "rows": [], + "unknownStageSegments": 0, + "unknownStageLabels": [] + }, + "rows": [] + }, + { + "index": 4, + "tz": "America/New_York", + "detailed": { + "anchor": "1970-01-01T00:00:00.000Z", + "windowStart": null, + "windowEnd": null, + "rows": [], + "unknownStageSegments": 0, + "unknownStageLabels": [] + }, + "rows": [] + }, + { + "index": 5, + "tz": null, + "detailed": { + "anchor": "users/me/dataTypes/sleep/dataPoints/sleep-0006", + "windowStart": "2026-06-08T01:10:00.000Z", + "windowEnd": "2026-06-08T01:40:00.000Z", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 30, + "unit": "minutes", + "measuredAt": "2026-06-08T01:40:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0006:sleep:2026-06-08T01:10:00.000Z", + "sleepStage": "CORE" + } + ], + "unknownStageSegments": 7, + "unknownStageLabels": [ + "NAP_STAGE_ALPHA", + "NAP_STAGE_BETA", + "NAP_STAGE_GAMMA", + "NAP_STAGE_DELTA", + "NAP_STAGE_EPSILON" + ] + }, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 30, + "unit": "minutes", + "measuredAt": "2026-06-08T01:40:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0006:sleep:2026-06-08T01:10:00.000Z", + "sleepStage": "CORE" + } + ] + }, + { + "index": 5, + "tz": "Europe/Berlin", + "detailed": { + "anchor": "users/me/dataTypes/sleep/dataPoints/sleep-0006", + "windowStart": "2026-06-08T01:10:00.000Z", + "windowEnd": "2026-06-08T01:40:00.000Z", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 30, + "unit": "minutes", + "measuredAt": "2026-06-08T01:40:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0006:sleep:2026-06-08T01:10:00.000Z", + "sleepStage": "CORE" + } + ], + "unknownStageSegments": 7, + "unknownStageLabels": [ + "NAP_STAGE_ALPHA", + "NAP_STAGE_BETA", + "NAP_STAGE_GAMMA", + "NAP_STAGE_DELTA", + "NAP_STAGE_EPSILON" + ] + }, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 30, + "unit": "minutes", + "measuredAt": "2026-06-08T01:40:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0006:sleep:2026-06-08T01:10:00.000Z", + "sleepStage": "CORE" + } + ] + }, + { + "index": 5, + "tz": "America/New_York", + "detailed": { + "anchor": "users/me/dataTypes/sleep/dataPoints/sleep-0006", + "windowStart": "2026-06-08T01:10:00.000Z", + "windowEnd": "2026-06-08T01:40:00.000Z", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 30, + "unit": "minutes", + "measuredAt": "2026-06-08T01:40:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0006:sleep:2026-06-08T01:10:00.000Z", + "sleepStage": "CORE" + } + ], + "unknownStageSegments": 7, + "unknownStageLabels": [ + "NAP_STAGE_ALPHA", + "NAP_STAGE_BETA", + "NAP_STAGE_GAMMA", + "NAP_STAGE_DELTA", + "NAP_STAGE_EPSILON" + ] + }, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 30, + "unit": "minutes", + "measuredAt": "2026-06-08T01:40:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0006:sleep:2026-06-08T01:10:00.000Z", + "sleepStage": "CORE" + } + ] + } +] diff --git a/src/lib/google-health/__tests__/backfill-bounds.test.ts b/src/lib/google-health/__tests__/backfill-bounds.test.ts index 7bc28ad46..fb22d2f79 100644 --- a/src/lib/google-health/__tests__/backfill-bounds.test.ts +++ b/src/lib/google-health/__tests__/backfill-bounds.test.ts @@ -43,12 +43,12 @@ import { googleHealthBackfillRetryPending, readGoogleHealthBackfillProgress, } from "../backfill-progress"; +import { forEachDataPointPage } from "@/lib/google-health/data-points"; import { - forEachDataPointPage, - GOOGLE_HEALTH_DATA_TYPES, noteGoogleHealthDeferred, runWithGoogleHealthClientOutcome, -} from "../client"; +} from "@/lib/google-health/budget"; +import { GOOGLE_HEALTH_DATA_TYPES } from "@/lib/google-health/mappers"; const MINUTE = 60_000; const T0 = Date.parse("2026-10-17T12:00:00Z"); diff --git a/src/lib/google-health/__tests__/backfill-resume-progress.test.ts b/src/lib/google-health/__tests__/backfill-resume-progress.test.ts index 80b2b2486..328d33d39 100644 --- a/src/lib/google-health/__tests__/backfill-resume-progress.test.ts +++ b/src/lib/google-health/__tests__/backfill-resume-progress.test.ts @@ -32,7 +32,7 @@ import { googleHealthSliceBudget, runWithGoogleHealthClientOutcome, runWithGoogleHealthStop, -} from "../client"; +} from "@/lib/google-health/budget"; beforeEach(() => { vi.clearAllMocks(); diff --git a/src/lib/google-health/__tests__/date-filter-fallback.test.ts b/src/lib/google-health/__tests__/date-filter-fallback.test.ts index 981468d66..4bf56d3dc 100644 --- a/src/lib/google-health/__tests__/date-filter-fallback.test.ts +++ b/src/lib/google-health/__tests__/date-filter-fallback.test.ts @@ -18,7 +18,8 @@ vi.mock("@/lib/safe-fetch", async (importOriginal) => { return { ...actual, safeFetch: safeFetchMock }; }); -import { GOOGLE_HEALTH_DATA_TYPES, fetchDataPoints } from "../client"; +import { fetchDataPoints } from "@/lib/google-health/data-points"; +import { GOOGLE_HEALTH_DATA_TYPES } from "@/lib/google-health/mappers"; import { WideEventBuilder } from "@/lib/logging/event-builder"; import { eventStorage } from "@/lib/logging/context"; import { GoogleHealthApiError } from "../response-classifier"; diff --git a/src/lib/google-health/__tests__/edge-filter-fallback.test.ts b/src/lib/google-health/__tests__/edge-filter-fallback.test.ts index 1c827912a..f93fca69e 100644 --- a/src/lib/google-health/__tests__/edge-filter-fallback.test.ts +++ b/src/lib/google-health/__tests__/edge-filter-fallback.test.ts @@ -32,15 +32,19 @@ vi.mock("@/lib/logging/context", () => ({ })); import { - GOOGLE_HEALTH_DATA_TYPES, GOOGLE_HEALTH_SKIP_MS, - type GoogleHealthDataPoint, forEachDataPointPage, - googleHealthSliceBudget, refusesEdgeFilter, +} from "@/lib/google-health/data-points"; +import { + googleHealthSliceBudget, runWithGoogleHealthClientOutcome, runWithGoogleHealthStop, -} from "../client"; +} from "@/lib/google-health/budget"; +import { + GOOGLE_HEALTH_DATA_TYPES, + type GoogleHealthDataPoint, +} from "@/lib/google-health/mappers"; import { GoogleHealthApiError } from "../response-classifier"; import { GOOGLE_HEALTH_NO_PROGRESS_PAGES } from "../backfill-bounds"; import { diff --git a/src/lib/google-health/__tests__/map-fixtures.test.ts b/src/lib/google-health/__tests__/map-fixtures.test.ts new file mode 100644 index 000000000..289a099fc --- /dev/null +++ b/src/lib/google-health/__tests__/map-fixtures.test.ts @@ -0,0 +1,462 @@ +/** + * What each Google Health mapper makes of a fixed set of payloads, pinned in + * `__golden__/map-*.json`. + * + * The fixtures under `__fixtures__/` are synthetic data points in the + * documented v4 wire shape (spot samples, daily summaries, dailyRollUp + * windows, sleep sessions, exercise sessions), chosen to walk every branch the + * mappers have: int64 values as strings, zeros and negatives, missing or + * unparseable anchors, offset-less local times, unknown stage and sport + * labels. Every mapper that takes a zone runs once without one and once each + * for `Europe/Berlin` and `America/New_York`. The pure request helpers from + * `client.ts` run over small input tables in the test itself. + * + * The goldens were written before the mappers moved out of `client.ts` and + * `mappers.ts` into their own modules, so a green run proves the move changed + * no output. The clock is pinned because a sample without a parseable anchor + * falls back to the current time. + */ +import { format } from "prettier"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import { + GOOGLE_HEALTH_DATA_TYPES, + googleHealthSampleInstant, + mapActiveEnergy, + mapBloodGlucose, + mapBodyFat, + mapCoreBodyTemperature, + mapDistance, + mapFloors, + mapGoogleHealthSleepStage, + mapGoogleHealthSportType, + mapHeartRate, + mapHeartRateVariability, + mapHeight, + mapOxygenSaturation, + mapRespiratoryRate, + mapRestingHeartRate, + mapSleepSession, + mapSleepSessionDetailed, + mapSteps, + mapVo2Max, + mapWeight, + mapWorkout, + mapWristTemperature, + type GoogleHealthDataPoint, + type GoogleHealthDataType, + type GoogleHealthRollupPoint, +} from "../mappers"; +import { + buildDailyRollUpBody, + chunkCivilRange, + civilDateInTz, + resolveGoogleHealthUserId, +} from "../client"; +import { refusesEdgeFilter } from "@/lib/google-health/data-points"; +import { + extractGoogleApiErrorDetail, + formatCivilBound, + googleHealthRetryDelayMs, + incrementalFilter, +} from "@/lib/google-health/request"; +import dailySummariesJson from "./__fixtures__/daily-summaries.json"; +import exerciseJson from "./__fixtures__/exercise.json"; +import rollupsJson from "./__fixtures__/rollups.json"; +import samplesJson from "./__fixtures__/samples.json"; +import sleepJson from "./__fixtures__/sleep.json"; + +/** The golden is written in the repository's JSON format, so the format check passes on it unchanged. */ +async function golden(value: unknown): Promise { + return format(JSON.stringify(value), { parser: "json" }); +} + +/** Captures a thrown error as data, so a refusal is part of the golden too. */ +function outcome(fn: () => T): T | { threw: string } { + try { + return fn(); + } catch (err) { + return { threw: err instanceof Error ? err.message : String(err) }; + } +} + +const samples: GoogleHealthDataPoint[] = samplesJson; +const dailySummaries: GoogleHealthDataPoint[] = dailySummariesJson; +const rollups: GoogleHealthRollupPoint[] = rollupsJson; +const sleepSessions: GoogleHealthDataPoint[] = sleepJson; +const exercises: GoogleHealthDataPoint[] = exerciseJson; + +const ZONES = [undefined, "Europe/Berlin", "America/New_York"] as const; + +type ZonedMapper = (point: P, tz?: string) => R; + +/** Each fixture point through the mapper for the union key it carries, in every zone. */ +function mapByKey

, R>( + points: P[], + mappers: Record>, +): Array<{ index: number; key: string; tz: string | null; output: R }> { + const out: Array<{ + index: number; + key: string; + tz: string | null; + output: R; + }> = []; + points.forEach((point, index) => { + for (const [key, mapper] of Object.entries(mappers)) { + if (!(key in point)) continue; + for (const tz of ZONES) { + out.push({ index, key, tz: tz ?? null, output: mapper(point, tz) }); + } + } + }); + if (out.length !== points.length * ZONES.length) { + throw new Error("a fixture point matched no mapper, or more than one"); + } + return out; +} + +const FIXED_NOW = new Date("2026-07-01T09:00:00.000Z"); + +beforeAll(() => { + vi.useFakeTimers({ now: FIXED_NOW, toFake: ["Date"] }); +}); + +afterAll(() => { + vi.useRealTimers(); +}); + +describe("Google Health mapper goldens", () => { + it("maps spot samples", async () => { + const output = { + measurements: mapByKey(samples, { + weight: mapWeight, + bodyFat: mapBodyFat, + heartRate: mapHeartRate, + bloodGlucose: mapBloodGlucose, + coreBodyTemperature: mapCoreBodyTemperature, + height: (point) => mapHeight(point), + }), + sampleInstants: samples.flatMap((point, index) => + Object.entries(GOOGLE_HEALTH_DATA_TYPES) + .filter(([, dt]) => dt.key in point) + .flatMap(([name, dt]) => + ZONES.map((tz) => ({ + index, + dataType: name, + tz: tz ?? null, + instant: googleHealthSampleInstant(point, dt, tz), + })), + ), + ), + sampleInstantOfDailyType: googleHealthSampleInstant( + dailySummaries[0]!, + GOOGLE_HEALTH_DATA_TYPES.oxygenSaturation, + ), + }; + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-samples.json", + ); + }); + + it("maps daily summaries", async () => { + const output = mapByKey(dailySummaries, { + dailyOxygenSaturation: mapOxygenSaturation, + dailyHeartRateVariability: mapHeartRateVariability, + dailyRestingHeartRate: mapRestingHeartRate, + dailyRespiratoryRate: mapRespiratoryRate, + dailySleepTemperatureDerivations: mapWristTemperature, + dailyVo2Max: mapVo2Max, + }); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-daily-summaries.json", + ); + }); + + it("maps dailyRollUp windows", async () => { + const output = mapByKey(rollups, { + steps: mapSteps, + distance: mapDistance, + activeEnergyBurned: mapActiveEnergy, + floors: mapFloors, + }); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-rollups.json", + ); + }); + + it("maps sleep sessions", async () => { + const output = sleepSessions.flatMap((point, index) => + ZONES.map((tz) => ({ + index, + tz: tz ?? null, + detailed: mapSleepSessionDetailed(point, tz), + rows: mapSleepSession(point, tz), + })), + ); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-sleep.json", + ); + }); + + it("maps exercise sessions", async () => { + const output = exercises.flatMap((point, index) => + ZONES.map((tz) => ({ + index, + tz: tz ?? null, + workout: mapWorkout(point, tz), + })), + ); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-exercise.json", + ); + }); + + it("resolves sleep stage and sport labels", async () => { + const stageLabels: unknown[] = [ + "AWAKE", + "wake", + "LIGHT", + " core ", + "Deep", + "REM", + "ASLEEP", + "RESTLESS", + "in bed", + "IN-BED", + "inbed", + "SLEEP_STAGE_TYPE_UNSPECIFIED", + "", + 42, + null, + ]; + const sportLabels: unknown[] = [ + "RUNNING", + "TREADMILL_RUNNING", + "STRENGTH_TRAINING", + "strength training", + "Mountain-Biking", + "STAIR_CLIMBER", + "STAIRCLIMBER", + "HIGH INTENSITY INTERVAL TRAINING", + "FOOTBALL", + "PILATES", + "OTHER_WORKOUT", + "KITESURFING", + "", + " ", + null, + 7, + ]; + const output = { + sleepStages: stageLabels.map((raw) => ({ + raw, + stage: mapGoogleHealthSleepStage(raw), + })), + sportTypes: sportLabels.map((raw) => ({ + raw, + sportType: mapGoogleHealthSportType(raw), + })), + }; + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-labels.json", + ); + }); + + it("builds request values with the pure client helpers", async () => { + const instants = [ + new Date("2026-06-01T21:59:59.000Z"), + new Date("2026-06-01T22:00:00.000Z"), + new Date("2026-03-29T00:30:00.000Z"), + new Date("2026-12-31T23:30:15.500Z"), + ]; + const listTypes: GoogleHealthDataType[] = [ + GOOGLE_HEALTH_DATA_TYPES.weight, + GOOGLE_HEALTH_DATA_TYPES.heartRateVariability, + GOOGLE_HEALTH_DATA_TYPES.sleep, + GOOGLE_HEALTH_DATA_TYPES.exercise, + GOOGLE_HEALTH_DATA_TYPES.steps, + ]; + const errorBodies: unknown[] = [ + { + error: { + code: 400, + status: "INVALID_ARGUMENT", + message: + "Invalid filter for https://health.googleapis.com/v4/users/me/dataTypes/weight/dataPoints?filter=weight.date", + details: [ + { + "@type": "type.googleapis.com/google.rpc.BadRequest", + fieldViolations: [ + { field: "filter", description: "Unknown field weight.date" }, + { field: "page_size" }, + { description: "Authorization: Bearer abc.def.ghi rejected" }, + { field: "fourth", description: "dropped past the third" }, + ], + }, + ], + }, + }, + { error: { status: "RESOURCE_EXHAUSTED" } }, + { error: { message: "x".repeat(260) } }, + { error: { details: [null, { fieldViolations: [{}, 5] }] } }, + { error: "invalid_grant" }, + { error: {} }, + null, + "plain text", + ]; + const retryCases: Array<{ + headers: Record; + body: unknown; + }> = [ + { headers: { "retry-after": "30" }, body: null }, + { headers: { "retry-after": "1.5" }, body: null }, + { + headers: { "retry-after": "Wed, 01 Jul 2026 09:00:45 GMT" }, + body: null, + }, + { headers: { "retry-after": "-3" }, body: null }, + { + headers: {}, + body: { + error: { + details: [ + { "@type": "type.googleapis.com/google.rpc.ErrorInfo" }, + { + "@type": "type.googleapis.com/google.rpc.RetryInfo", + retryDelay: "12.5s", + }, + ], + }, + }, + }, + { + headers: {}, + body: { error: { details: [{ retryDelay: "soon" }] } }, + }, + { headers: {}, body: null }, + ]; + const output = { + resolveGoogleHealthUserId: [ + { name: "users/1234567890" }, + { name: "users/", id: "id-0002" }, + { name: "plain-0003" }, + { id: "id-0004" }, + {}, + ].map((profile) => ({ + profile, + userId: resolveGoogleHealthUserId(profile), + })), + formatCivilBound: instants.flatMap((instant) => + ZONES.map((tz) => ({ + instant, + tz: tz ?? null, + bound: formatCivilBound(instant, tz), + })), + ), + civilDateInTz: instants.flatMap((instant) => + ZONES.map((tz) => ({ + instant, + tz: tz ?? null, + date: civilDateInTz(instant, tz), + })), + ), + incrementalFilter: listTypes.flatMap((dataType) => + (["camel", "snake"] as const).flatMap((style) => + ZONES.map((tz) => ({ + dataType: dataType.path, + style, + tz: tz ?? null, + filter: outcome(() => + incrementalFilter(dataType, instants[1]!, tz, style), + ), + })), + ), + ), + incrementalFilterDefaultStyle: incrementalFilter( + GOOGLE_HEALTH_DATA_TYPES.restingHeartRate, + instants[0]!, + ), + chunkCivilRange: [ + { + start: { year: 2026, month: 1, day: 1 }, + end: { year: 2026, month: 7, day: 1 }, + }, + { + start: { year: 2026, month: 2, day: 20 }, + end: { year: 2026, month: 3, day: 10 }, + maxDays: 14, + }, + { + start: { year: 2026, month: 6, day: 1 }, + end: { year: 2026, month: 6, day: 1 }, + }, + { + start: { year: 2026, month: 6, day: 2 }, + end: { year: 2026, month: 6, day: 1 }, + }, + ].map(({ start, end, maxDays }) => ({ + start, + end, + maxDays: maxDays ?? null, + chunks: chunkCivilRange(start, end, maxDays), + })), + buildDailyRollUpBody: [ + { + chunk: { + start: { year: 2026, month: 1, day: 1 }, + end: { year: 2026, month: 4, day: 1 }, + }, + }, + { + chunk: { + start: { year: 2026, month: 2, day: 20 }, + end: { year: 2026, month: 3, day: 1 }, + }, + pageToken: "page-token-0002", + }, + { + chunk: { + start: { year: 2026, month: 12, day: 31 }, + end: { year: 2027, month: 1, day: 1 }, + }, + pageToken: "", + }, + ].map(({ chunk, pageToken }) => ({ + chunk, + pageToken: pageToken ?? null, + body: buildDailyRollUpBody(chunk, pageToken), + })), + extractGoogleApiErrorDetail: errorBodies.map((body) => ({ + body, + detail: extractGoogleApiErrorDetail(body) ?? null, + })), + refusesEdgeFilter: [ + undefined, + "INVALID_ARGUMENT", + "INVALID_ARGUMENT: Invalid filter", + "INVALID_ARGUMENT: Invalid page token", + "INVALID_ARGUMENT: page_size out of range", + "INVALID_ARGUMENT: pageSize too large", + "UNAUTHENTICATED: access token expired", + "FAILED_PRECONDITION: something else", + "Bad filter and bad token", + ].map((detail) => ({ + detail: detail ?? null, + refuses: refusesEdgeFilter(detail), + })), + googleHealthRetryDelayMs: retryCases.map(({ headers, body }) => ({ + headers, + body, + delayMs: + googleHealthRetryDelayMs( + new Response(null, { status: 429, headers }), + body, + FIXED_NOW.getTime(), + ) ?? null, + })), + }; + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-client-helpers.json", + ); + }); +}); diff --git a/src/lib/google-health/__tests__/mappers.test.ts b/src/lib/google-health/__tests__/mappers.test.ts index 36be47ff2..1d805d99d 100644 --- a/src/lib/google-health/__tests__/mappers.test.ts +++ b/src/lib/google-health/__tests__/mappers.test.ts @@ -14,10 +14,13 @@ */ import { describe, expect, it } from "vitest"; +import { chunkCivilRange } from "../client"; +import { mapVo2Max } from "@/lib/google-health/mappers"; import { - chunkCivilRange, formatCivilBound, incrementalFilter, +} from "@/lib/google-health/request"; +import { GOOGLE_HEALTH_DATA_TYPES, mapActiveEnergy, mapBloodGlucose, @@ -36,11 +39,10 @@ import { mapSleepSession, mapSleepSessionDetailed, mapSteps, - mapVo2Max, mapWeight, mapWorkout, mapWristTemperature, -} from "../client"; +} from "@/lib/google-health/mappers"; describe("mapGoogleHealthSportType", () => { it("keeps Badminton as a first-class workout sport", () => { diff --git a/src/lib/google-health/__tests__/rate-limit-and-token.test.ts b/src/lib/google-health/__tests__/rate-limit-and-token.test.ts index 4a80869fb..8a1c04868 100644 --- a/src/lib/google-health/__tests__/rate-limit-and-token.test.ts +++ b/src/lib/google-health/__tests__/rate-limit-and-token.test.ts @@ -21,12 +21,12 @@ vi.mock("@/lib/safe-fetch", async (importOriginal) => { return { ...actual, safeFetch: safeFetchMock }; }); +import { fetchDataPoints } from "@/lib/google-health/data-points"; import { - GOOGLE_HEALTH_DATA_TYPES, GOOGLE_HEALTH_RATE_LIMIT, - fetchDataPoints, googleHealthRetryDelayMs, -} from "../client"; +} from "@/lib/google-health/request"; +import { GOOGLE_HEALTH_DATA_TYPES } from "@/lib/google-health/mappers"; import { collapseNaturalKeyTwins } from "../sync-core"; import { GoogleHealthApiError } from "../response-classifier"; diff --git a/src/lib/google-health/__tests__/rollup-request.test.ts b/src/lib/google-health/__tests__/rollup-request.test.ts index 770010be4..99eda4c04 100644 --- a/src/lib/google-health/__tests__/rollup-request.test.ts +++ b/src/lib/google-health/__tests__/rollup-request.test.ts @@ -20,15 +20,15 @@ vi.mock("@/lib/safe-fetch", async (importOriginal) => { }); import { - GOOGLE_HEALTH_DATA_TYPES, GOOGLE_HEALTH_ROLLUP_FALLBACK_RANGE_DAYS, GOOGLE_HEALTH_ROLLUP_RANGE_DAYS, buildDailyRollUpBody, - extractGoogleApiErrorDetail, fetchDailyRollUp, - runWithGoogleHealthClientOutcome, type GoogleHealthRollupShape, } from "../client"; +import { extractGoogleApiErrorDetail } from "@/lib/google-health/request"; +import { runWithGoogleHealthClientOutcome } from "@/lib/google-health/budget"; +import { GOOGLE_HEALTH_DATA_TYPES } from "@/lib/google-health/mappers"; import { GoogleHealthApiError } from "../response-classifier"; function jsonResponse(status: number, body: unknown): Response { diff --git a/src/lib/google-health/__tests__/scopes.test.ts b/src/lib/google-health/__tests__/scopes.test.ts index 824511b9d..272bcf00e 100644 --- a/src/lib/google-health/__tests__/scopes.test.ts +++ b/src/lib/google-health/__tests__/scopes.test.ts @@ -4,7 +4,7 @@ import { GOOGLE_HEALTH_CORE_SCOPES, getGoogleHealthScopeString, resolveGoogleHealthScopes, -} from "../client"; +} from "@/lib/google-health/oauth"; describe("Google Health scope resolver", () => { it("ships exactly the four core Restricted read scopes", () => { diff --git a/src/lib/google-health/__tests__/setup-docs-parity.test.ts b/src/lib/google-health/__tests__/setup-docs-parity.test.ts index 4f295e2db..9e8a3a094 100644 --- a/src/lib/google-health/__tests__/setup-docs-parity.test.ts +++ b/src/lib/google-health/__tests__/setup-docs-parity.test.ts @@ -18,7 +18,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { GOOGLE_HEALTH_CORE_SCOPES } from "../client"; +import { GOOGLE_HEALTH_CORE_SCOPES } from "@/lib/google-health/oauth"; import { INTEGRATION_DOCS_BASE, integrationDocsHref, diff --git a/src/lib/google-health/__tests__/sync-activity-failsoft.test.ts b/src/lib/google-health/__tests__/sync-activity-failsoft.test.ts index 7c08c9bcd..ab1570d9a 100644 --- a/src/lib/google-health/__tests__/sync-activity-failsoft.test.ts +++ b/src/lib/google-health/__tests__/sync-activity-failsoft.test.ts @@ -16,11 +16,11 @@ const { fetchDailyRollUpMock, fetchDataPointsMock, handleErrorMock } = vi.mock("../client", async (importOriginal) => { const actual = await importOriginal(); - return { - ...actual, - fetchDailyRollUp: fetchDailyRollUpMock, - fetchDataPoints: fetchDataPointsMock, - }; + return { ...actual, fetchDailyRollUp: fetchDailyRollUpMock }; +}); +vi.mock("../data-points", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, fetchDataPoints: fetchDataPointsMock }; }); vi.mock("../sync-core", () => ({ getValidToken: vi.fn(async () => ({ diff --git a/src/lib/google-health/__tests__/sync-deferred-rollup-ledger.test.ts b/src/lib/google-health/__tests__/sync-deferred-rollup-ledger.test.ts index cea735f92..9bbeca946 100644 --- a/src/lib/google-health/__tests__/sync-deferred-rollup-ledger.test.ts +++ b/src/lib/google-health/__tests__/sync-deferred-rollup-ledger.test.ts @@ -30,7 +30,7 @@ vi.mock("@/lib/integrations/status", () => ({ recordSyncFailure: vi.fn(async () => {}), recordSyncSuccess: vi.fn(async () => {}), })); -vi.mock("@/lib/insights/comprehensive-generate", () => ({ +vi.mock("@/lib/insights/status-invalidation", () => ({ invalidateStatusInsightsForTypes: vi.fn(async () => {}), })); vi.mock("@/lib/arrivals/measurement-emit", () => ({ diff --git a/src/lib/google-health/__tests__/sync-failsoft.test.ts b/src/lib/google-health/__tests__/sync-failsoft.test.ts index 64c09bee9..46cb8d9cf 100644 --- a/src/lib/google-health/__tests__/sync-failsoft.test.ts +++ b/src/lib/google-health/__tests__/sync-failsoft.test.ts @@ -76,7 +76,7 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({ recomputeBucketsForMeasurement: vi.fn(async () => {}), recomputeUserRollups: vi.fn(async () => {}), })); -vi.mock("@/lib/insights/comprehensive-generate", () => ({ +vi.mock("@/lib/insights/status-invalidation", () => ({ invalidateStatusInsightsForTypes: vi.fn(async () => {}), })); vi.mock("@/lib/cache/invalidate", () => ({ @@ -85,9 +85,15 @@ vi.mock("@/lib/cache/invalidate", () => ({ vi.mock("../credentials", () => ({ getUserGoogleHealthCredentials: getUserGoogleHealthCredentialsMock, })); -vi.mock("../client", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, refreshAccessToken: refreshAccessTokenMock }; +vi.mock("../oauth", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + googleHealthOAuth: { + ...actual.googleHealthOAuth, + refresh: refreshAccessTokenMock, + }, + }; }); vi.mock("../sync-metrics", () => ({ syncUserMetrics: resourceFake })); diff --git a/src/lib/google-health/__tests__/sync-fairness.test.ts b/src/lib/google-health/__tests__/sync-fairness.test.ts index 8f3aaf35f..5351336a3 100644 --- a/src/lib/google-health/__tests__/sync-fairness.test.ts +++ b/src/lib/google-health/__tests__/sync-fairness.test.ts @@ -66,7 +66,7 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({ collapseToTypeDayKeys: vi.fn(() => []), recomputeUserRollups: vi.fn(async () => {}), })); -vi.mock("@/lib/insights/comprehensive-generate", () => ({ +vi.mock("@/lib/insights/status-invalidation", () => ({ invalidateStatusInsightsForTypes: vi.fn(async () => {}), })); vi.mock("../sync-workout", () => ({ syncUserWorkout: resources.workout })); diff --git a/src/lib/google-health/__tests__/sync-intraday-daily-wide.test.ts b/src/lib/google-health/__tests__/sync-intraday-daily-wide.test.ts index 92e68d395..37386c3cb 100644 --- a/src/lib/google-health/__tests__/sync-intraday-daily-wide.test.ts +++ b/src/lib/google-health/__tests__/sync-intraday-daily-wide.test.ts @@ -44,7 +44,7 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({ collapseToTypeDayKeys: vi.fn(() => []), recomputeUserRollups: vi.fn(async () => {}), })); -vi.mock("@/lib/insights/comprehensive-generate", () => ({ +vi.mock("@/lib/insights/status-invalidation", () => ({ invalidateStatusInsightsForTypes: vi.fn(async () => {}), })); vi.mock("../sync-workout", () => ({ syncUserWorkout: vi.fn(async () => 0) })); diff --git a/src/lib/google-health/__tests__/sync-metrics-dense-pages.test.ts b/src/lib/google-health/__tests__/sync-metrics-dense-pages.test.ts index becd43db9..4410552c2 100644 --- a/src/lib/google-health/__tests__/sync-metrics-dense-pages.test.ts +++ b/src/lib/google-health/__tests__/sync-metrics-dense-pages.test.ts @@ -46,10 +46,8 @@ vi.mock("@/lib/tz/resolver", () => ({ resolveUserTimezone: vi.fn(async () => "UTC"), })); -import { - GOOGLE_HEALTH_PAGE_SIZE, - runWithGoogleHealthClientOutcome, -} from "../client"; +import { GOOGLE_HEALTH_PAGE_SIZE } from "@/lib/google-health/data-points"; +import { runWithGoogleHealthClientOutcome } from "@/lib/google-health/budget"; import { syncUserMetrics } from "../sync-metrics"; const PAGES = 3; diff --git a/src/lib/google-health/__tests__/sync-metrics-failsoft.test.ts b/src/lib/google-health/__tests__/sync-metrics-failsoft.test.ts index 937523c14..d0a9d1227 100644 --- a/src/lib/google-health/__tests__/sync-metrics-failsoft.test.ts +++ b/src/lib/google-health/__tests__/sync-metrics-failsoft.test.ts @@ -22,8 +22,8 @@ const { fetchDataPointsMock, handleErrorMock, mapWeightMock } = vi.hoisted( }), ); -vi.mock("../client", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../data-points", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, fetchDataPoints: fetchDataPointsMock, @@ -44,9 +44,12 @@ vi.mock("../client", async (importOriginal) => { if (points.length > 0) await onPage(points); }, ), - mapWeight: mapWeightMock, }; }); +vi.mock("../mappers", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, mapWeight: mapWeightMock }; +}); vi.mock("../sync-core", () => ({ getValidToken: vi.fn(async () => ({ diff --git a/src/lib/google-health/__tests__/sync-outcomes.test.ts b/src/lib/google-health/__tests__/sync-outcomes.test.ts index 1aae53140..1f80ae21e 100644 --- a/src/lib/google-health/__tests__/sync-outcomes.test.ts +++ b/src/lib/google-health/__tests__/sync-outcomes.test.ts @@ -142,7 +142,7 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({ collapseToTypeDayKeys: vi.fn(() => []), recomputeUserRollups: vi.fn(async () => {}), })); -vi.mock("@/lib/insights/comprehensive-generate", () => ({ +vi.mock("@/lib/insights/status-invalidation", () => ({ invalidateStatusInsightsForTypes: vi.fn(async () => {}), })); vi.mock("../sync-core", () => ({ diff --git a/src/lib/google-health/__tests__/sync-plausibility.test.ts b/src/lib/google-health/__tests__/sync-plausibility.test.ts index 2fb5b3404..e5136e07b 100644 --- a/src/lib/google-health/__tests__/sync-plausibility.test.ts +++ b/src/lib/google-health/__tests__/sync-plausibility.test.ts @@ -35,7 +35,7 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({ recomputeBucketsForMeasurement: vi.fn(async () => {}), recomputeUserRollups: vi.fn(async () => {}), })); -vi.mock("@/lib/insights/comprehensive-generate", () => ({ +vi.mock("@/lib/insights/status-invalidation", () => ({ invalidateStatusInsightsForTypes: vi.fn(async () => {}), })); vi.mock("../credentials", () => ({ @@ -45,12 +45,18 @@ vi.mock("../client", async () => { const actual = await vi.importActual("../client"); return { ...actual, - refreshAccessToken: vi.fn(), noteGoogleHealthMapped: vi.fn(), noteGoogleHealthWritten: vi.fn(), noteGoogleHealthOutcomeFailure: vi.fn(), }; }); +vi.mock("../oauth", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + googleHealthOAuth: { ...actual.googleHealthOAuth, refresh: vi.fn() }, + }; +}); import { eventStorage } from "@/lib/logging/context"; import { WideEventBuilder } from "@/lib/logging/event-builder"; diff --git a/src/lib/google-health/__tests__/sync-sleep-morning-refresh.test.ts b/src/lib/google-health/__tests__/sync-sleep-morning-refresh.test.ts index 4ca2f771b..eb647f726 100644 --- a/src/lib/google-health/__tests__/sync-sleep-morning-refresh.test.ts +++ b/src/lib/google-health/__tests__/sync-sleep-morning-refresh.test.ts @@ -26,10 +26,12 @@ const { maybeEnqueueMorningRefreshMock: vi.fn(async () => {}), })); -vi.mock("../client", () => ({ +vi.mock("../data-points", () => ({ GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE: 100, - GOOGLE_HEALTH_DATA_TYPES: { sleep: "sleep" }, fetchDataPoints: fetchDataPointsMock, +})); +vi.mock("../mappers", () => ({ + GOOGLE_HEALTH_DATA_TYPES: { sleep: "sleep" }, mapSleepSessionDetailed: mapSleepSessionDetailedMock, })); diff --git a/src/lib/google-health/__tests__/sync-sleep-replace.test.ts b/src/lib/google-health/__tests__/sync-sleep-replace.test.ts index c7302712c..180111464 100644 --- a/src/lib/google-health/__tests__/sync-sleep-replace.test.ts +++ b/src/lib/google-health/__tests__/sync-sleep-replace.test.ts @@ -26,13 +26,19 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({ recomputeBucketsForMeasurement: vi.fn(async () => {}), recomputeUserRollups: vi.fn(async () => {}), })); -vi.mock("@/lib/insights/comprehensive-generate", () => ({ +vi.mock("@/lib/insights/status-invalidation", () => ({ invalidateStatusInsightsForTypes: vi.fn(async () => {}), })); vi.mock("../credentials", () => ({ getUserGoogleHealthCredentials: vi.fn(async () => null), })); -vi.mock("../client", () => ({ refreshAccessToken: vi.fn() })); +vi.mock("../oauth", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + googleHealthOAuth: { ...actual.googleHealthOAuth, refresh: vi.fn() }, + }; +}); import { replaceStaleGoogleHealthSleep } from "../sync-core"; diff --git a/src/lib/google-health/__tests__/sync-time-budget.test.ts b/src/lib/google-health/__tests__/sync-time-budget.test.ts index 8b2bd3b4b..0925858b9 100644 --- a/src/lib/google-health/__tests__/sync-time-budget.test.ts +++ b/src/lib/google-health/__tests__/sync-time-budget.test.ts @@ -46,7 +46,7 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({ collapseToTypeDayKeys: vi.fn(() => []), recomputeUserRollups: vi.fn(async () => {}), })); -vi.mock("@/lib/insights/comprehensive-generate", () => ({ +vi.mock("@/lib/insights/status-invalidation", () => ({ invalidateStatusInsightsForTypes: vi.fn(async () => {}), })); vi.mock("../sync-workout", () => ({ syncUserWorkout: resources.workout })); @@ -54,7 +54,7 @@ vi.mock("../sync-sleep", () => ({ syncUserSleep: resources.sleep })); vi.mock("../sync-activity", () => ({ syncUserActivity: resources.activity })); vi.mock("../sync-metrics", () => ({ syncUserMetrics: resources.metrics })); -import { googleHealthSliceExhausted } from "../client"; +import { googleHealthSliceExhausted } from "@/lib/google-health/budget"; import { syncUserGoogleHealth } from "../sync"; beforeEach(() => { diff --git a/src/lib/google-health/__tests__/sync-upsert-resurrect.test.ts b/src/lib/google-health/__tests__/sync-upsert-resurrect.test.ts index 0704e346c..fc09f1ac8 100644 --- a/src/lib/google-health/__tests__/sync-upsert-resurrect.test.ts +++ b/src/lib/google-health/__tests__/sync-upsert-resurrect.test.ts @@ -39,15 +39,18 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({ recomputeBucketsForMeasurement: vi.fn(async () => {}), recomputeUserRollups: vi.fn(async () => {}), })); -vi.mock("@/lib/insights/comprehensive-generate", () => ({ +vi.mock("@/lib/insights/status-invalidation", () => ({ invalidateStatusInsightsForTypes: vi.fn(async () => {}), })); vi.mock("../credentials", () => ({ getUserGoogleHealthCredentials: vi.fn(async () => null), })); -vi.mock("../client", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, refreshAccessToken: vi.fn() }; +vi.mock("../oauth", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + googleHealthOAuth: { ...actual.googleHealthOAuth, refresh: vi.fn() }, + }; }); import { upsertGoogleHealthMeasurements } from "../sync-core"; diff --git a/src/lib/google-health/__tests__/sync-workout-insert-identity.test.ts b/src/lib/google-health/__tests__/sync-workout-insert-identity.test.ts index b92e7fb97..5440c037a 100644 --- a/src/lib/google-health/__tests__/sync-workout-insert-identity.test.ts +++ b/src/lib/google-health/__tests__/sync-workout-insert-identity.test.ts @@ -29,10 +29,12 @@ vi.mock("@/lib/logging/context", () => ({ vi.mock("@/lib/tz/resolver", () => ({ resolveUserTimezone: vi.fn(async () => "UTC"), })); -vi.mock("../client", () => ({ +vi.mock("../data-points", () => ({ GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE: 100, - GOOGLE_HEALTH_DATA_TYPES: { exercise: "exercise" }, fetchDataPoints, +})); +vi.mock("../mappers", () => ({ + GOOGLE_HEALTH_DATA_TYPES: { exercise: "exercise" }, mapWorkout, })); vi.mock("../sync-core", () => ({ diff --git a/src/lib/google-health/__tests__/walk-resume-and-retry.test.ts b/src/lib/google-health/__tests__/walk-resume-and-retry.test.ts index 4054883b7..b8f3dffe4 100644 --- a/src/lib/google-health/__tests__/walk-resume-and-retry.test.ts +++ b/src/lib/google-health/__tests__/walk-resume-and-retry.test.ts @@ -22,14 +22,14 @@ vi.mock("@/lib/logging/context", () => ({ })); import { SafeFetchError } from "@/lib/safe-fetch"; +import { forEachDataPointPage } from "@/lib/google-health/data-points"; +import { GOOGLE_HEALTH_TRANSIENT_RETRY } from "@/lib/google-health/request"; import { - GOOGLE_HEALTH_DATA_TYPES, - GOOGLE_HEALTH_TRANSIENT_RETRY, - forEachDataPointPage, googleHealthSliceBudget, runWithGoogleHealthClientOutcome, runWithGoogleHealthStop, -} from "../client"; +} from "@/lib/google-health/budget"; +import { GOOGLE_HEALTH_DATA_TYPES } from "@/lib/google-health/mappers"; import { GoogleHealthApiError } from "../response-classifier"; function page(n: number, next: boolean, status = 200, headers = {}): Response { diff --git a/src/lib/google-health/budget.ts b/src/lib/google-health/budget.ts new file mode 100644 index 000000000..8fb703ac0 --- /dev/null +++ b/src/lib/google-health/budget.ts @@ -0,0 +1,278 @@ +/** + * The limits a Google Health walk runs under and what it reports back. + * + * A walk asks before each request whether to stop: on the job's own budget + * (the full-history backfill runs under its pg-boss job's budget and abort + * signal), and, in a backfill slice, on the slice's tighter limit of minutes + * and pages. Each walk also adds to the per-resource outcome tally the cycle + * reports (pages, fetched, mapped, written, truncated, reason). Both live in + * async-local scopes the sync opens, so the walker needs no parameters for + * them. + */ +import { AsyncLocalStorage } from "node:async_hooks"; + +export interface GoogleHealthClientOutcome { + pages: number; + fetched: number; + mapped: number; + written: number; + truncated: boolean; + reasonCode: + | "collection_failed" + | "token_failed" + | "upsert_failed" + | "rollup_failed" + | "existing_page_limit" + | null; +} + +const clientOutcomeStorage = new AsyncLocalStorage(); + +function addOutcomeCount(current: number, value: number): number { + if (!Number.isFinite(value) || value <= 0) return current; + return Math.min(2_147_483_647, current + Math.trunc(value)); +} + +export function notePagination( + pages: number, + fetched: number, + truncated: boolean, +): void { + const tracker = clientOutcomeStorage.getStore(); + if (!tracker) return; + tracker.pages = addOutcomeCount(tracker.pages, pages); + tracker.fetched = addOutcomeCount(tracker.fetched, fetched); + if (truncated) { + tracker.truncated = true; + tracker.reasonCode = "existing_page_limit"; + } +} + +export function noteGoogleHealthMapped(count: number): void { + const tracker = clientOutcomeStorage.getStore(); + if (tracker) tracker.mapped = addOutcomeCount(tracker.mapped, count); +} + +export function noteGoogleHealthWritten(count: number): void { + const tracker = clientOutcomeStorage.getStore(); + if (tracker) tracker.written = addOutcomeCount(tracker.written, count); +} + +export function noteGoogleHealthOutcomeFailure( + reasonCode: Exclude, +): void { + const tracker = clientOutcomeStorage.getStore(); + if (tracker && !tracker.reasonCode) tracker.reasonCode = reasonCode; +} + +/** Whether a walk in the current resource scope has ended short so far. */ +export function googleHealthOutcomeTruncated(): boolean { + return clientOutcomeStorage.getStore()?.truncated === true; +} + +export async function runWithGoogleHealthClientOutcome( + fn: () => Promise, +): Promise<{ result: T; outcome: GoogleHealthClientOutcome }> { + const outcome: GoogleHealthClientOutcome = { + pages: 0, + fetched: 0, + mapped: 0, + written: 0, + truncated: false, + reasonCode: null, + }; + const result = await clientOutcomeStorage.run(outcome, fn); + return { result, outcome }; +} + +/** + * A stop condition for the walks below, set by a caller that runs under a + * time limit (the full-history backfill, which runs under its pg-boss job's + * budget and abort signal). A walk asks it before each request and, when it + * says stop, ends there and reports itself truncated, the same outcome as + * reaching the page ceiling: the resource is incomplete, the cycle is not + * stamped, and the reason shown is that only part of the history was + * processed. Outside such a scope the walks never stop early. + * + * A backfill slice adds a second, tighter limit (`GoogleHealthSliceBudget`): + * minutes and pages rather than the job's hours. Only a walk that keeps its + * place across attempts (`resumable`) honours it, because only such a walk + * loses nothing by stopping; a walk that would start over from its first page + * runs on to the end under the job's limit alone. + */ +interface GoogleHealthWalkLimits { + shouldStop: () => boolean; + slice?: GoogleHealthSliceBudget; +} + +/** Why a backfill slice stopped walking. */ +export type GoogleHealthSliceStopReason = + "time_budget" | "page_budget" | "rate_limited" | "job_budget" | "lane_wanted"; + +/** + * One backfill slice's share of the work: a span of wall-clock time and a + * page count, both well inside the job's own expiry. The clock starts the + * first time a walk asks, which is after the slice has the account's lock: a + * slice that waited for an hourly sync to finish does not start out with its + * time already spent. `pages` counts every page the slice's walks have read; + * `stopReason` is set the first time a limit is reached and stays set, so + * every walk after it stops too. + */ +export interface GoogleHealthSliceBudget { + ms: number; + /** When the slice's time is up; null until the first walk asks. */ + deadline: number | null; + maxPages: number; + pages: number; + /** + * Of `pages`, those a walk only passed over on its way to its edge, because + * Google refused the edge filter (see `forEachDataPointPage`). They are not + * charged against `maxPages`. Their time is: a skip runs on the slice's + * clock like any other read, and on its own shorter limit + * (`GOOGLE_HEALTH_SKIP_MS`), and keeps its place for the next slice when it + * stops. Only a skip that had to start over from the newest page, because + * its saved place was refused, moves `deadline` on by the time it took. + */ + skippedPages: number; + stopReason: GoogleHealthSliceStopReason | null; + now: () => number; + /** + * Whether another heavy job waits for the lane this slice holds + * (`laneWanted` in `jobs/heavy-lane.ts`). Asked where the slice's own + * limits are, so giving way keeps every walk's place, and only once the + * slice has read a page, so the slice it ends has something on disk and is + * continued rather than failed. + */ + yieldWanted: () => boolean; +} + +/** A fresh slice budget of `ms` milliseconds and `maxPages` pages. */ +export function googleHealthSliceBudget( + ms: number, + maxPages: number, + now: () => number = Date.now, + yieldWanted: () => boolean = () => false, +): GoogleHealthSliceBudget { + return { + ms, + deadline: null, + maxPages, + pages: 0, + skippedPages: 0, + stopReason: null, + now, + yieldWanted, + }; +} + +const stopStorage = new AsyncLocalStorage(); + +/** Whether the slice in scope has used its budget (and why, once it has). */ +function sliceExhausted(slice: GoogleHealthSliceBudget): boolean { + if (slice.stopReason) return true; + slice.deadline ??= slice.now() + slice.ms; + if (slice.now() >= slice.deadline) slice.stopReason = "time_budget"; + else if (slice.pages - slice.skippedPages >= slice.maxPages) { + slice.stopReason = "page_budget"; + } else if (slice.pages > 0 && slice.yieldWanted()) { + slice.stopReason = "lane_wanted"; + } + return slice.stopReason !== null; +} + +/** + * Whether a walk should stop before its next request. Every walk stops on the + * job's limit; a resumable one also stops on the slice's. + */ +export function stopRequested(resumable = false): boolean { + const limits = stopStorage.getStore(); + if (!limits) return false; + if (limits.shouldStop()) { + if (limits.slice && !limits.slice.stopReason) { + limits.slice.stopReason = "job_budget"; + } + return true; + } + return ( + resumable && limits.slice !== undefined && sliceExhausted(limits.slice) + ); +} + +/** + * Whether the backfill slice in scope has used its budget. A collection that + * has not started yet waits for the next slice rather than starting now. + * False outside a slice. + */ +export function googleHealthSliceExhausted(): boolean { + const limits = stopStorage.getStore(); + if (!limits) return false; + if (limits.shouldStop()) { + if (limits.slice && !limits.slice.stopReason) { + limits.slice.stopReason = "job_budget"; + } + return true; + } + return limits.slice !== undefined && sliceExhausted(limits.slice); +} + +/** Pages the slice in scope has read so far (0 outside a slice). */ +export function googleHealthSlicePagesRead(): number { + return stopStorage.getStore()?.slice?.pages ?? 0; +} + +/** Count one page read against the slice in scope, if any. */ +export function noteSlicePage(): void { + const slice = stopStorage.getStore()?.slice; + if (slice) slice.pages += 1; +} + +/** + * Note one page the walk only passed over on its way to its edge: read, but + * not charged against the slice's pages (no-op outside a slice). + */ +export function noteSliceSkip(): void { + const slice = stopStorage.getStore()?.slice; + if (slice) slice.skippedPages += 1; +} + +/** + * Give back to the slice in scope the time a skip took that had to start over + * from the newest page (no-op outside a slice). + */ +export function extendSliceDeadline(ms: number): void { + const slice = stopStorage.getStore()?.slice; + if (slice && slice.deadline !== null) slice.deadline += ms; +} + +/** The slice clock in scope, or the wall clock outside a slice. */ +export function sliceNow(): number { + return stopStorage.getStore()?.slice?.now() ?? Date.now(); +} + +/** End the slice in scope for `reason` (no-op outside a slice). */ +export function endSlice(reason: GoogleHealthSliceStopReason): void { + const slice = stopStorage.getStore()?.slice; + if (slice && !slice.stopReason) slice.stopReason = reason; +} + +/** Whether a resumable walk can stop and keep its place here. */ +export function inSlice(): boolean { + return stopStorage.getStore()?.slice !== undefined; +} + +export async function runWithGoogleHealthStop( + shouldStop: () => boolean, + fn: () => Promise, + slice?: GoogleHealthSliceBudget, +): Promise { + return stopStorage.run({ shouldStop, slice }, fn); +} + +/** + * Mark the resource in scope truncated without a walk: a collection a slice + * left for the next one. The cycle then reports itself incomplete, so the + * backfill does not mistake a skipped collection for an empty one. + */ +export function noteGoogleHealthDeferred(): void { + notePagination(0, 0, true); +} diff --git a/src/lib/google-health/client.ts b/src/lib/google-health/client.ts index dbdd331b3..0228a7a53 100644 --- a/src/lib/google-health/client.ts +++ b/src/lib/google-health/client.ts @@ -1,615 +1,37 @@ /** - * Google Health API client — OAuth + data-point reads (v1.27.0). + * Google Health API endpoints: the profile read that gives a connection its + * external user id, and the daily roll-up reads. * Docs: https://developers.google.com/health (health.googleapis.com/v4, the - * successor to the Fitbit Web API, unifying Fitbit + Pixel Watch + Fitbit Air). + * successor to the Fitbit Web API, unifying Fitbit, Pixel Watch and Fitbit + * Air). * - * Hand-rolled fetch over `safeFetch` (no SDK): the OAuth handshake - * (`getAuthorizationUrl` / `exchangeCode` / `refreshAccessToken`), a single - * profile fetch for the connection's external user id, and the paginated - * `dataPoints.list` walker + per-type mappers. - * - * KEY OAUTH SEMANTICS (verified 2026 contract): - * - Access-token TTL = 1 h (contrast the classic Fitbit Web API's 8 h). - * - Refresh tokens do NOT rotate — a routine refresh returns a fresh - * `access_token` WITHOUT a `refresh_token`; the sync layer keeps the stored - * one (see `getValidToken` in `sync.ts`). Refresh tokens are time-based: - * they expire after 6 months of disuse, or — in a consent screen still in - * "Testing" publishing mode — after 7 DAYS, at which point the user must - * re-consent. A revoked / expired refresh token surfaces `invalid_grant` - * (or a 401) on the token endpoint; `postToken` lifts that onto the - * `reauth_required` class so the connection prompts a reconnect rather than - * a generic hard error. - * - PKCE (S256): the authorize request carries a `code_challenge`; the - * callback presents the matching `code_verifier` on exchange. Google's - * web-server (confidential) client also sends the client secret via HTTP - * Basic auth (RFC 6749 §2.3.1) — Basic + PKCE together. - * - `access_type=offline` + `prompt=consent` are required to reliably receive - * a refresh token (and force one on every re-consent). - * - * The pure mapping layer (payload → Measurement / Workout shapes) lives in - * `mappers.ts` and is re-exported below, so the sync layer and tests keep a - * single `./client` import surface. The shared wire-shape symbols - * (`GoogleHealthDataType`, `GOOGLE_HEALTH_DATA_TYPES`, the point types) live in - * `mappers.ts` and are imported here type-only — that direction keeps the - * module graph acyclic (`client` → `mappers`, never back). + * The integration is split by concern: the OAuth handshake is `./oauth.ts`, + * the request layer (error detail, retry, rate limits) `./request.ts`, the + * paginated `dataPoints.list` walker `./data-points.ts`, the backfill slice + * budget and the per-cycle outcome tally `./budget.ts`, and the pure payload + * mapping `./mappers.ts`. */ -import { AsyncLocalStorage } from "node:async_hooks"; -import { createHash, randomBytes } from "node:crypto"; import { annotate, getEvent } from "@/lib/logging/context"; -import { SafeFetchError, safeFetch } from "@/lib/safe-fetch"; +import { safeFetch } from "@/lib/safe-fetch"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import { GoogleHealthApiError, classifyGoogleHealthResponse, - type GoogleHealthClassification, } from "./response-classifier"; import { - googleHealthSampleInstant, - type GoogleHealthDataPoint, type GoogleHealthDataType, type GoogleHealthRollupPoint, } from "./mappers"; -import { envValue } from "@/lib/env"; -import { - GOOGLE_HEALTH_NO_PROGRESS_PAGES, - googleHealthWalkProgress, - noteGoogleHealthWalkPage, -} from "./backfill-bounds"; + import { walkPages } from "@/lib/integrations/page-walk"; -import { retryAfterMsOf } from "@/lib/integrations/oauth-kit/retry-after"; -export * from "./mappers"; - -export interface GoogleHealthClientOutcome { - pages: number; - fetched: number; - mapped: number; - written: number; - truncated: boolean; - reasonCode: - | "collection_failed" - | "token_failed" - | "upsert_failed" - | "rollup_failed" - | "existing_page_limit" - | null; -} - -const clientOutcomeStorage = new AsyncLocalStorage(); - -function addOutcomeCount(current: number, value: number): number { - if (!Number.isFinite(value) || value <= 0) return current; - return Math.min(2_147_483_647, current + Math.trunc(value)); -} - -function notePagination( - pages: number, - fetched: number, - truncated: boolean, -): void { - const tracker = clientOutcomeStorage.getStore(); - if (!tracker) return; - tracker.pages = addOutcomeCount(tracker.pages, pages); - tracker.fetched = addOutcomeCount(tracker.fetched, fetched); - if (truncated) { - tracker.truncated = true; - tracker.reasonCode = "existing_page_limit"; - } -} - -export function noteGoogleHealthMapped(count: number): void { - const tracker = clientOutcomeStorage.getStore(); - if (tracker) tracker.mapped = addOutcomeCount(tracker.mapped, count); -} - -export function noteGoogleHealthWritten(count: number): void { - const tracker = clientOutcomeStorage.getStore(); - if (tracker) tracker.written = addOutcomeCount(tracker.written, count); -} - -export function noteGoogleHealthOutcomeFailure( - reasonCode: Exclude, -): void { - const tracker = clientOutcomeStorage.getStore(); - if (tracker && !tracker.reasonCode) tracker.reasonCode = reasonCode; -} - -/** Whether a walk in the current resource scope has ended short so far. */ -export function googleHealthOutcomeTruncated(): boolean { - return clientOutcomeStorage.getStore()?.truncated === true; -} - -export async function runWithGoogleHealthClientOutcome( - fn: () => Promise, -): Promise<{ result: T; outcome: GoogleHealthClientOutcome }> { - const outcome: GoogleHealthClientOutcome = { - pages: 0, - fetched: 0, - mapped: 0, - written: 0, - truncated: false, - reasonCode: null, - }; - const result = await clientOutcomeStorage.run(outcome, fn); - return { result, outcome }; -} - -/** - * A stop condition for the walks below, set by a caller that runs under a - * time limit (the full-history backfill, which runs under its pg-boss job's - * budget and abort signal). A walk asks it before each request and, when it - * says stop, ends there and reports itself truncated, the same outcome as - * reaching the page ceiling: the resource is incomplete, the cycle is not - * stamped, and the reason shown is that only part of the history was - * processed. Outside such a scope the walks never stop early. - * - * A backfill slice adds a second, tighter limit (`GoogleHealthSliceBudget`): - * minutes and pages rather than the job's hours. Only a walk that keeps its - * place across attempts (`resumable`) honours it, because only such a walk - * loses nothing by stopping; a walk that would start over from its first page - * runs on to the end under the job's limit alone. - */ -interface GoogleHealthWalkLimits { - shouldStop: () => boolean; - slice?: GoogleHealthSliceBudget; -} - -/** Why a backfill slice stopped walking. */ -export type GoogleHealthSliceStopReason = - "time_budget" | "page_budget" | "rate_limited" | "job_budget" | "lane_wanted"; - -/** - * One backfill slice's share of the work: a span of wall-clock time and a - * page count, both well inside the job's own expiry. The clock starts the - * first time a walk asks, which is after the slice has the account's lock: a - * slice that waited for an hourly sync to finish does not start out with its - * time already spent. `pages` counts every page the slice's walks have read; - * `stopReason` is set the first time a limit is reached and stays set, so - * every walk after it stops too. - */ -export interface GoogleHealthSliceBudget { - ms: number; - /** When the slice's time is up; null until the first walk asks. */ - deadline: number | null; - maxPages: number; - pages: number; - /** - * Of `pages`, those a walk only passed over on its way to its edge, because - * Google refused the edge filter (see `forEachDataPointPage`). They are not - * charged against `maxPages`. Their time is: a skip runs on the slice's - * clock like any other read, and on its own shorter limit - * (`GOOGLE_HEALTH_SKIP_MS`), and keeps its place for the next slice when it - * stops. Only a skip that had to start over from the newest page, because - * its saved place was refused, moves `deadline` on by the time it took. - */ - skippedPages: number; - stopReason: GoogleHealthSliceStopReason | null; - now: () => number; - /** - * Whether another heavy job waits for the lane this slice holds - * (`laneWanted` in `jobs/heavy-lane.ts`). Asked where the slice's own - * limits are, so giving way keeps every walk's place, and only once the - * slice has read a page, so the slice it ends has something on disk and is - * continued rather than failed. - */ - yieldWanted: () => boolean; -} - -/** A fresh slice budget of `ms` milliseconds and `maxPages` pages. */ -export function googleHealthSliceBudget( - ms: number, - maxPages: number, - now: () => number = Date.now, - yieldWanted: () => boolean = () => false, -): GoogleHealthSliceBudget { - return { - ms, - deadline: null, - maxPages, - pages: 0, - skippedPages: 0, - stopReason: null, - now, - yieldWanted, - }; -} - -const stopStorage = new AsyncLocalStorage(); - -/** Whether the slice in scope has used its budget (and why, once it has). */ -function sliceExhausted(slice: GoogleHealthSliceBudget): boolean { - if (slice.stopReason) return true; - slice.deadline ??= slice.now() + slice.ms; - if (slice.now() >= slice.deadline) slice.stopReason = "time_budget"; - else if (slice.pages - slice.skippedPages >= slice.maxPages) { - slice.stopReason = "page_budget"; - } else if (slice.pages > 0 && slice.yieldWanted()) { - slice.stopReason = "lane_wanted"; - } - return slice.stopReason !== null; -} - -/** - * Whether a walk should stop before its next request. Every walk stops on the - * job's limit; a resumable one also stops on the slice's. - */ -function stopRequested(resumable = false): boolean { - const limits = stopStorage.getStore(); - if (!limits) return false; - if (limits.shouldStop()) { - if (limits.slice && !limits.slice.stopReason) { - limits.slice.stopReason = "job_budget"; - } - return true; - } - return ( - resumable && limits.slice !== undefined && sliceExhausted(limits.slice) - ); -} - -/** - * Whether the backfill slice in scope has used its budget. A collection that - * has not started yet waits for the next slice rather than starting now. - * False outside a slice. - */ -export function googleHealthSliceExhausted(): boolean { - const limits = stopStorage.getStore(); - if (!limits) return false; - if (limits.shouldStop()) { - if (limits.slice && !limits.slice.stopReason) { - limits.slice.stopReason = "job_budget"; - } - return true; - } - return limits.slice !== undefined && sliceExhausted(limits.slice); -} - -/** Pages the slice in scope has read so far (0 outside a slice). */ -export function googleHealthSlicePagesRead(): number { - return stopStorage.getStore()?.slice?.pages ?? 0; -} - -/** Count one page read against the slice in scope, if any. */ -function noteSlicePage(): void { - const slice = stopStorage.getStore()?.slice; - if (slice) slice.pages += 1; -} - -/** - * Note one page the walk only passed over on its way to its edge: read, but - * not charged against the slice's pages (no-op outside a slice). - */ -function noteSliceSkip(): void { - const slice = stopStorage.getStore()?.slice; - if (slice) slice.skippedPages += 1; -} - -/** - * Give back to the slice in scope the time a skip took that had to start over - * from the newest page (no-op outside a slice). - */ -function extendSliceDeadline(ms: number): void { - const slice = stopStorage.getStore()?.slice; - if (slice && slice.deadline !== null) slice.deadline += ms; -} - -/** The slice clock in scope, or the wall clock outside a slice. */ -function sliceNow(): number { - return stopStorage.getStore()?.slice?.now() ?? Date.now(); -} - -/** End the slice in scope for `reason` (no-op outside a slice). */ -function endSlice(reason: GoogleHealthSliceStopReason): void { - const slice = stopStorage.getStore()?.slice; - if (slice && !slice.stopReason) slice.stopReason = reason; -} - -/** Whether a resumable walk can stop and keep its place here. */ -function inSlice(): boolean { - return stopStorage.getStore()?.slice !== undefined; -} - -export async function runWithGoogleHealthStop( - shouldStop: () => boolean, - fn: () => Promise, - slice?: GoogleHealthSliceBudget, -): Promise { - return stopStorage.run({ shouldStop, slice }, fn); -} - -/** - * Mark the resource in scope truncated without a walk: a collection a slice - * left for the next one. The cycle then reports itself incomplete, so the - * backfill does not mistake a skipped collection for an empty one. - */ -export function noteGoogleHealthDeferred(): void { - notePagination(0, 0, true); -} - -export const GOOGLE_HEALTH_API_BASE = "https://health.googleapis.com/v4"; -const GOOGLE_OAUTH_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; -const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token"; - -export interface GoogleHealthCredentials { - clientId: string; - clientSecret: string; -} - -/** - * Resolve the OAuth `redirect_uri`, then assert it against an allowlist - * (defence-in-depth). - * - * The value is operator-controlled config (`GOOGLE_HEALTH_REDIRECT_URI`, else - * derived from `NEXT_PUBLIC_APP_URL`), not user input, so Google's - * registered-redirect check is the real backstop. But a misconfigured or - * `Host`-coerced `NEXT_PUBLIC_APP_URL` (a mis-deployed reverse proxy reflecting - * a forwarded Host) would otherwise send the authorization code's landing URL - * off-origin. Pin the target here so a malformed origin fails fast at the - * handshake rather than silently redirecting elsewhere: - * - must be an absolute, parseable URL, - * - must be https (the one exception is a localhost/loopback dev host, which - * Google itself permits over http), - * - must land on the fixed `/api/google-health/callback` path, - * - when derived from `NEXT_PUBLIC_APP_URL`, must stay same-origin with it. - */ -export function getGoogleHealthRedirectUri(): string { - // An empty or blank value counts as unset: the compose whitelist - // materialises an unset var as an empty string, which must fall through to - // the derived URI rather than read as "not configured". - const explicit = envValue("GOOGLE_HEALTH_REDIRECT_URI"); - const appUrl = envValue("NEXT_PUBLIC_APP_URL"); - const raw = - explicit ?? (appUrl ? `${appUrl}/api/google-health/callback` : undefined); - - if (!raw) { - throw new Error( - "Google Health redirect_uri is not configured — set GOOGLE_HEALTH_REDIRECT_URI or NEXT_PUBLIC_APP_URL", - ); - } - - let parsed: URL; - try { - parsed = new URL(raw); - } catch { - throw new Error( - `Google Health redirect_uri is not an absolute URL: ${raw}`, - ); - } - - const isLoopback = - parsed.hostname === "localhost" || - parsed.hostname === "127.0.0.1" || - parsed.hostname === "[::1]" || - parsed.hostname === "::1"; - if ( - parsed.protocol !== "https:" && - !(parsed.protocol === "http:" && isLoopback) - ) { - throw new Error( - `Google Health redirect_uri must be https (or http on localhost): ${parsed.origin}`, - ); - } - - if (parsed.pathname !== "/api/google-health/callback") { - throw new Error( - `Google Health redirect_uri must target /api/google-health/callback, got ${parsed.pathname}`, - ); - } - - // When an explicit GOOGLE_HEALTH_REDIRECT_URI is set alongside - // NEXT_PUBLIC_APP_URL, require them to share an origin so the pinned value - // can't drift to an unexpected host relative to the app's own base URL. - if (explicit && appUrl) { - let appOrigin: string; - try { - appOrigin = new URL(appUrl).origin; - } catch { - throw new Error(`NEXT_PUBLIC_APP_URL is not an absolute URL: ${appUrl}`); - } - if (parsed.origin !== appOrigin) { - throw new Error( - `Google Health redirect_uri origin ${parsed.origin} does not match NEXT_PUBLIC_APP_URL origin ${appOrigin}`, - ); - } - } - - return parsed.toString(); -} - -/** - * The four core Restricted read scopes HealthLog requests for v1. Every scope - * is Restricted → the operator's OAuth client needs Google verification + an - * annual CASA assessment before it leaves "Testing" publishing mode (staying in - * Testing with ≤100 test users avoids CASA at the cost of a 7-day refresh-token - * expiry). - */ -export const GOOGLE_HEALTH_CORE_SCOPES = [ - "https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly", - "https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly", - "https://www.googleapis.com/auth/googlehealth.sleep.readonly", - "https://www.googleapis.com/auth/googlehealth.profile.readonly", -] as const; - -/** - * Resolve the scope list HealthLog requests: the four core Restricted bundles. - * ECG/IRN are a future enhancement — add those two Restricted read scopes only - * together with an ECG/IRN reader, never before (Google penalizes requesting a - * Restricted scope the app never consumes). - */ -export function resolveGoogleHealthScopes(): string[] { - return [...GOOGLE_HEALTH_CORE_SCOPES]; -} - -/** The space-separated scope string sent on the authorize request. */ -export function getGoogleHealthScopeString(): string { - return resolveGoogleHealthScopes().join(" "); -} - -// ─── PKCE ────────────────────────────────────────────────────── -// -// Google's authorization-code flow accepts S256 PKCE. The verifier is a -// high-entropy random string; the challenge is BASE64URL(SHA256(verifier)). The -// verifier is stashed on the OAuth-state row at connect and presented on the -// token exchange at callback — never in the cookie or the URL. - -export interface GoogleHealthPkcePair { - verifier: string; - challenge: string; -} - -/** - * Mint a PKCE verifier + S256 challenge. 64 random bytes → 86 base64url chars, - * comfortably inside the RFC 7636 43–128 char verifier range and well past the - * 256-bit entropy floor. - */ -export function generatePkcePair(): GoogleHealthPkcePair { - const verifier = randomBytes(64).toString("base64url"); - const challenge = createHash("sha256").update(verifier).digest("base64url"); - return { verifier, challenge }; -} - -/** - * Build the Google authorization URL (a browser redirect, not a fetch). `state` - * is the opaque CSRF nonce minted by `oauth-state.ts`; `codeChallenge` is the - * S256 PKCE challenge whose verifier the callback presents on token exchange. - * - * `access_type=offline` + `prompt=consent` are Google's requirement to receive - * a refresh token; `prompt=consent` forces the consent screen so a re-connect - * always returns a fresh refresh token even if the user previously granted. - */ -export function getAuthorizationUrl( - state: string, - creds: GoogleHealthCredentials, - codeChallenge: string, -): string { - const params = new URLSearchParams({ - response_type: "code", - client_id: creds.clientId, - redirect_uri: getGoogleHealthRedirectUri(), - scope: getGoogleHealthScopeString(), - access_type: "offline", - prompt: "consent", - code_challenge: codeChallenge, - code_challenge_method: "S256", - state, - }); - return `${GOOGLE_OAUTH_AUTH_URL}?${params}`; -} - -export interface GoogleHealthTokenResponse { - access_token: string; - /** - * Present on the initial code exchange and whenever Google issues a new - * refresh token; ABSENT on a routine refresh because Google does not rotate. - * The sync layer keeps the stored token when this is missing. - */ - refresh_token?: string; - expires_in: number; - scope?: string; - token_type?: string; -} - -/** Basic-auth header carrying the confidential client credentials. */ -function basicAuthHeader(creds: GoogleHealthCredentials): string { - const raw = `${creds.clientId}:${creds.clientSecret}`; - return `Basic ${Buffer.from(raw, "utf8").toString("base64")}`; -} - -async function postToken( - params: URLSearchParams, - creds: GoogleHealthCredentials, - verb: string, -): Promise { - const start = performance.now(); - const res = await safeFetch(GOOGLE_OAUTH_TOKEN_URL, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Authorization: basicAuthHeader(creds), - }, - body: params.toString(), - }); - - const json = await res.json().catch(() => null); - const verdict = classifyGoogleHealthResponse(res.status); - const upstreamError = - typeof json?.error === "string" ? (json.error as string) : undefined; - - // Google signals a revoked / expired refresh token — the user disconnected - // the app upstream, OR the 7-day "Testing"-mode refresh window lapsed — via - // `invalid_grant` on the token endpoint (sometimes a 400, sometimes a 401). A - // bare status classification buckets a 400 as `persistent` (never prompts a - // reconnect); lift an `invalid_grant` onto `reauth_required` so the connection - // surfaces the reconnect CTA. A 401 already classifies as `reauth_required`. - const classification: GoogleHealthClassification = - upstreamError === "invalid_grant" - ? "reauth_required" - : verdict.classification; - - getEvent()?.addExternalCall({ - service: "google-health", - method: verb, - duration_ms: Math.round(performance.now() - start), - status: res.status, - error: verdict.classification === "success" ? undefined : verdict.reason, - }); - if (classification !== "success") { - throw new GoogleHealthApiError({ - verb, - classification, - httpStatus: verdict.httpStatus, - reason: verdict.reason, - upstreamError, - }); - } - return json as GoogleHealthTokenResponse; -} - -/** - * Exchange an authorization code for the initial token pair, presenting the - * PKCE verifier. `redirect_uri` must exactly match the one sent to the - * authorize endpoint. - */ -export async function exchangeCode( - code: string, - codeVerifier: string, - creds: GoogleHealthCredentials, -): Promise { - return postToken( - new URLSearchParams({ - grant_type: "authorization_code", - code, - client_id: creds.clientId, - redirect_uri: getGoogleHealthRedirectUri(), - code_verifier: codeVerifier, - }), - creds, - "exchangeCode", - ); -} - -/** - * Refresh an expired access token. Google does NOT rotate refresh tokens — the - * response carries a fresh `access_token` + `expires_in` but usually omits - * `refresh_token`. The caller persists the new access token + expiry and keeps - * the stored refresh token unless a new one is returned. The original scope is - * preserved by Google, so no `scope` param is re-sent. A revoked / expired - * refresh token throws a `GoogleHealthApiError` classified `reauth_required`. - */ -export async function refreshAccessToken( - refreshToken: string, - creds: GoogleHealthCredentials, -): Promise { - return postToken( - new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: creds.clientId, - }), - creds, - "refreshAccessToken", - ); -} +import { notePagination, stopRequested, noteSlicePage } from "./budget"; +import { + extractGoogleApiErrorDetail, + GoogleHealthAccessToken, + googleHealthDataRequest, +} from "./request"; +import { GOOGLE_HEALTH_API_BASE } from "./request"; /** * The Google Health profile carries the external user id used as the @@ -682,910 +104,6 @@ export function resolveGoogleHealthUserId( return "me"; } -// ─── Data-point reads (Google Health `dataPoints.list` / `:dailyRollUp`) ────── -// -// The Google Health API exposes one uniform `DataPoint` resource shape across -// every data type. Spot / daily-summary / session types are read through -// `GET /v4/users/me/dataTypes/{dataType}/dataPoints` with `nextPageToken` -// pagination; the cumulative activity types (steps / distance / active energy / -// floors) are read through `POST …/dataPoints:dailyRollUp` — their `list` -// surface returns minute-grain observation buckets (or, for floors, does not -// exist at all), so the daily totals MUST come from the rollup. -// -// Casing gotcha (three encodings per type, all pinned in -// `GOOGLE_HEALTH_DATA_TYPES` so a fetcher can never mix them up): -// - request path: kebab-case (`body-fat`) -// - `filter` predicate: snake_case (`body_fat.sample_time.physical_time`) — -// EXCEPT daily-summary `.date` filters, where the docs contradict -// themselves and the worked example uses the camelCase payload key; the -// client sends camel first and falls back to snake on a first-page 400 -// (see `GoogleHealthDateFilterStyle`). -// - response payload: camelCase — the `DataPoint` value is a union keyed by -// the camelCase type name (`bodyFat`, `dailyRestingHeartRate`, …) with -// camelCase nested objects (`sampleTime.physicalTime`, -// `interval.startTime`, `civilStartTime.date`). -// - -// proto3 int64 fields arrive as JSON **strings** (`"12345"`) — every numeric -// extractor coerces numeric strings before the finite check. - -/** - * Page-size ceiling for `dataPoints.list`. The daily/intraday reads default to - * 1440 (one-per-minute) and cap at 10 000; sleep/exercise cap at 25. The launch - * metrics use the daily/spot reads, so the default page size is the larger. - */ -export const GOOGLE_HEALTH_PAGE_SIZE = 1000; -/** Sleep/exercise read cap — matches the Google Health 25-cap for those types. */ -export const GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE = 25; - -/** - * Page ceiling for one intraday heart-rate walk. - * - * Per walk, never across walks: a backfill reads at most a slice's pages per - * walk and keeps its place between slices, and an hourly sync reads a few - * pages. What stops a walk that keeps going without getting anywhere is the - * progress guard (`GOOGLE_HEALTH_NO_PROGRESS_PAGES`), and what bounds a - * backfill that does get somewhere is its horizon in years (see - * `backfill-bounds.ts`). The walk is paged (`forEachDataPointPage`), so the - * ceiling costs time, not memory. - */ -export const GOOGLE_HEALTH_DENSE_MAX_PAGES = 10_000; - -/** Page ceiling for every other list walk. */ -export const GOOGLE_HEALTH_DEFAULT_MAX_PAGES = 1000; - -/** `dataPoints.list` envelope: `{ dataPoints, nextPageToken }`. */ -interface GoogleHealthDataPointPage { - dataPoints?: GoogleHealthDataPoint[]; - nextPageToken?: string | null; -} - -interface DataPointQuery { - /** Lower-bound incremental cursor; omitted on a full backfill. */ - start?: Date; - /** Page size (defaults to `GOOGLE_HEALTH_PAGE_SIZE`). */ - pageSize?: number; - /** Hard ceiling on pages walked (defence against a runaway cursor). */ - maxPages?: number; - /** - * The user's IANA zone — needed only by the `civilStart` filter (the - * offset-less civil bound must be the watermark's wall clock in the USER'S - * zone). Omitted → the bound forms in UTC. - */ - tz?: string; - /** - * Observes which daily-summary `.date` filter prefix style the walk settled - * on (`camel` worked-example vs `snake` fallback) — the structure probe - * surfaces it so a live account reports which grammar Google accepted. - */ - onDateFilterStyle?: (style: GoogleHealthDateFilterStyle) => void; - /** - * The walk keeps its place across attempts (a backfill's resume cursor), so - * it may stop on the backfill slice's budget and on a rate limit it cannot - * wait out, and report itself truncated instead of failing: the next slice - * picks up from the edge it recorded. - */ - resumable?: boolean; - /** - * Where an earlier attempt of this walk got to: the oldest sample it wrote - * when the walk ran newest first (`desc`), the newest when it ran oldest - * first (`asc`). The walk asks only for the samples at or beyond it; the - * one sample on the edge itself is read again and overwrites in place. - * - * `filter: false` reads without the edge bound and passes over the samples - * beyond the edge here instead (see `forEachDataPointPage`); the walk falls - * back to that on its own when Google refuses the bound, and calls - * `onFilterUnsupported` once when it does. `tz` reads an offset-less sample - * time the way the edge was recorded. Passing over has no page bound of - * its own: the pages passed over move toward the edge, and a walk whose - * pages stop moving is stopped by the progress guard like any other. - * - * A walk without the bound keeps its place in that list across slices: - * `resumeFrom` is where an earlier slice's walk stopped, and - * `onResumePoint` is told where this one stops (see - * `GoogleHealthResumePoint`). - */ - edge?: { - at: Date; - order: "desc" | "asc"; - filter?: boolean; - tz?: string; - onFilterUnsupported?: () => void | Promise; - resumeFrom?: GoogleHealthResumePoint; - onResumePoint?: (point: GoogleHealthResumePoint) => void | Promise; - }; - /** - * Told once when the walk is stopped for going - * `GOOGLE_HEALTH_NO_PROGRESS_PAGES` pages in a row without reaching any - * further back (see `backfill-bounds.ts`). The walk then reports itself - * truncated, as it does on its page ceiling. - */ - onNoProgress?: () => void | Promise; -} - -/** - * Where a walk without the edge bound stopped in the unbounded list: the page - * token that fetched the last page it read, and that page's position (pages - * from the newest one, which is position 0). The next slice starts by reading - * that page again rather than the newest one, so it passes over only what is - * left between it and the edge instead of everything earlier slices wrote. - * - * Re-reading the last page, not the one after it, is what lets the walk check - * the token still points where it did: the page must hold a sample at or - * beyond the edge. A list that lost samples in between could otherwise move - * the token past samples nobody wrote. A page that fails the check, or a - * token Google refuses, sends the walk back to the newest page, the way it - * read before it kept its place. - */ -export interface GoogleHealthResumePoint { - token: string; - page: number; -} - -/** - * The longest a walk passes over samples beyond its edge in one slice before - * it stops and keeps its place for the next. Shorter than the slice, so a - * slice that has to skip still writes, and never longer than it: a skip runs - * on the slice's clock and the account's lock is held no longer than a slice. - */ -export const GOOGLE_HEALTH_SKIP_MS = 3 * 60 * 1000; - -const pad2 = (n: number): string => String(n).padStart(2, "0"); - -/** - * Format a UTC instant as the offset-less civil wall clock an observer in `tz` - * reads at that moment (`YYYY-MM-DDTHH:MM:SS`, no `Z`, no offset) — the bound - * format the session `civil_start_time` filter expects. Without `tz` the bound - * forms in UTC. - */ -export function formatCivilBound(instant: Date, tz?: string): string { - if (!tz) return instant.toISOString().slice(0, 19); - const p = wallClockInTz(instant, tz); - return `${p.year}-${pad2(p.month)}-${pad2(p.day)}T${pad2(p.hour)}:${pad2(p.minute)}:${pad2(p.second)}`; -} - -/** - * The daily-summary `.date` filter prefix style. The official docs contradict - * themselves on this one point: - * - The data-types index states "In a filter parameter … the data type name - * must be in snake case, such as `body_fat`" and its per-type "filter - * parameter" column lists `daily_heart_rate_variability` etc. - * - The `dataPoints.list` reference's ONLY worked daily-summary example is - * `dailyHeartRateVariability.date < "2024-08-15"` — the camelCase payload - * union key, NOT snake_case. - * Live behaviour: the snake_case daily filter is accepted (HTTP 200) but has - * returned zero rows on accounts whose companion app visibly holds daily HRV / - * RHR — consistent with a vacuously-unmatched predicate. The client therefore - * sends the worked-example camelCase form first and falls back to snake_case - * once if the very first page is rejected with a 400 (see `fetchDataPoints`). - */ -export type GoogleHealthDateFilterStyle = "camel" | "snake"; - -/** - * Build the incremental `filter` field + bound for one list-read data type. - * Centralised so the predicate and the read-time anchor resolution can never - * drift. The legal filter fields are per-shape (anything else 400s): - * - `sample` → `{filter}.sample_time.physical_time` (RFC-3339). - * - `date` → `{key|filter}.date` (`YYYY-MM-DD`) — prefix style per - * `GoogleHealthDateFilterStyle` (docs conflict; camel is the worked - * example, snake the fallback). - * - `sessionEnd` → sleep only: `{filter}.interval.end_time` (RFC-3339) — the - * ONLY filterable time field on sleep; watermark semantics improve too (a - * night is fetched when it ENDS after the cursor). - * - `civilStart` → exercise: `{filter}.interval.civil_start_time` with an - * offset-less civil bound in the user's zone. - * `rollup` types never build a list filter — they read via `:dailyRollUp`. - */ -export function incrementalFilter( - dataType: GoogleHealthDataType, - start: Date, - tz?: string, - dateStyle: GoogleHealthDateFilterStyle = "camel", -): { field: string; bound: string } { - switch (dataType.timeField) { - case "sample": - return { - field: `${dataType.filter}.sample_time.physical_time`, - bound: start.toISOString(), - }; - case "sessionEnd": - return { - field: `${dataType.filter}.interval.end_time`, - bound: start.toISOString(), - }; - case "civilStart": - return { - field: `${dataType.filter}.interval.civil_start_time`, - bound: formatCivilBound(start, tz), - }; - case "date": - return { - field: `${dateStyle === "camel" ? dataType.key : dataType.filter}.date`, - // eslint-disable-next-line healthlog/no-utc-day-key -- baseline: request window edge for the provider API, not a displayed day; rows carry their own dates - bound: start.toISOString().slice(0, 10), - }; - case "rollup": - throw new Error( - `Google Health data type ${dataType.path} reads via :dailyRollUp, not dataPoints.list`, - ); - } -} - -/** - * Redact + bound an upstream Google API error message before it reaches an - * error object / wide event: strip bearer tokens, drop query strings from any - * embedded URL (they can carry filters echoing user data or tokens), cap at - * 200 chars. - */ -function redactApiErrorMessage(msg: string): string { - return msg - .replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]") - .replace(/(https?:\/\/[^\s"'?]+)\?[^\s"']*/gi, "$1?[REDACTED]") - .slice(0, 200); -} - -/** - * Collect the `google.rpc.BadRequest` field violations out of an AIP-193 - * `error.details[]` array as `field: description` fragments. Only the field - * PATH and the description sentence survive — both run through the message - * redactor and a per-fragment cap, and at most three violations are kept, so - * no request value can ride an error string into the logs. - */ -function extractFieldViolations(details: unknown): string[] { - if (!Array.isArray(details)) return []; - const out: string[] = []; - for (const d of details) { - if (!d || typeof d !== "object") continue; - const fv = (d as { fieldViolations?: unknown }).fieldViolations; - if (!Array.isArray(fv)) continue; - for (const v of fv) { - if (out.length >= 3) return out; - if (!v || typeof v !== "object") continue; - const o = v as { field?: unknown; description?: unknown }; - const field = - typeof o.field === "string" - ? redactApiErrorMessage(o.field).slice(0, 80) - : undefined; - const description = - typeof o.description === "string" - ? redactApiErrorMessage(o.description).slice(0, 120) - : undefined; - if (!field && !description) continue; - out.push([field, description].filter(Boolean).join(": ")); - } - } - return out; -} - -/** - * Extract the AIP-193 error envelope (`{"error":{code,message,status,details}}`) - * from a non-2xx Google Health body into a short redacted - * `STATUS: message [field: description]` detail string, or undefined when the - * body carries no such envelope. This is what makes a field-grammar 400 - * (`INVALID_ARGUMENT: Invalid filter …`) diagnosable from operator logs: the - * `details[]` BadRequest field violations name the exact offending request - * field. (The OAuth token endpoint uses the flat `{"error":"invalid_grant"}` - * shape instead — handled in `postToken`.) - */ -export function extractGoogleApiErrorDetail(json: unknown): string | undefined { - if (!json || typeof json !== "object") return undefined; - const e = (json as { error?: unknown }).error; - if (!e || typeof e !== "object") return undefined; - const o = e as { status?: unknown; message?: unknown; details?: unknown }; - const status = typeof o.status === "string" ? o.status : undefined; - const message = - typeof o.message === "string" - ? redactApiErrorMessage(o.message) - : undefined; - const violations = extractFieldViolations(o.details); - if (!status && !message && violations.length === 0) return undefined; - const head = [status, message].filter(Boolean).join(": "); - const tail = violations.length > 0 ? `[${violations.join("; ")}]` : ""; - return [head, tail].filter(Boolean).join(" ").slice(0, 500); -} - -// ─── Token source + one data request ─────────────────────────── - -/** - * Where a walk gets its access token. A plain string is used as given. A - * function is asked before every request, so a walk that runs for longer than - * the token lives (an access token lasts an hour; a full-history heart-rate - * walk can take several) picks up the refreshed one instead of carrying an - * expired token into a 401. `forceRefresh` asks for a new token even though - * the current one has not reached its expiry, after Google refused it. - */ -export type GoogleHealthTokenSource = (opts: { - forceRefresh: boolean; -}) => Promise; - -export type GoogleHealthAccessToken = string | GoogleHealthTokenSource; - -/** - * How a data request sits out Google's per-user, per-minute quota (429 - * `RESOURCE_EXHAUSTED`). The quota refills within the minute, so a short wait - * turns a throttled request into a completed one, where failing it used to - * cost the whole collection. The waits follow `Retry-After` (or the - * `google.rpc.RetryInfo` delay in the body) when Google sends one, and - * otherwise double from `baseDelayMs`: 2, 4, 8, 16, 32 s, a minute and a - * bit in all, which spans a full quota window. A requested delay above - * `maxDelayMs` is not slept out; the request fails as the transient it is - * and the next run picks the window up. - */ -export const GOOGLE_HEALTH_RATE_LIMIT = { - maxRetries: 5, - baseDelayMs: 2_000, - maxDelayMs: 60_000, -} as const; - -/** - * The delay Google asked for on a 429, in ms: the `Retry-After` header in - * either of its forms, else a `google.rpc.RetryInfo` detail (`"30s"`) in the - * error body. Undefined when it asked for nothing readable. - */ -export function googleHealthRetryDelayMs( - res: Response, - json: unknown, - now: number = Date.now(), -): number | undefined { - const fromHeader = retryAfterMsOf(res, now); - if (fromHeader !== undefined) return fromHeader; - const details = (json as { error?: { details?: unknown } } | null)?.error - ?.details; - if (Array.isArray(details)) { - for (const d of details) { - const delay = (d as { retryDelay?: unknown } | null)?.retryDelay; - if (typeof delay === "string") { - const m = /^(\d+(?:\.\d+)?)s$/.exec(delay.trim()); - if (m) return Math.round(Number.parseFloat(m[1]!) * 1000); - } - } - } - return undefined; -} - -/** - * How a data request rides out a server that did not answer properly: a - * 500, 502, 503 or 504, a timeout, or a broken connection. A short history - * reads a handful of pages and rarely meets one; a full-history heart-rate - * walk reads thousands, and a single one used to end the walk and throw away - * everything it had read. Three tries more, 2, 4 and 8 s apart (or the - * `Retry-After` a 503 names, up to a minute), before the request fails. - */ -export const GOOGLE_HEALTH_TRANSIENT_RETRY = { - maxRetries: 3, - baseDelayMs: 2_000, -}; - -const GOOGLE_HEALTH_TRANSIENT_STATUSES = new Set([500, 502, 503, 504]); - -function waitMs(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -interface GoogleHealthDataResponse { - status: number; - json: T | null; - verdict: ReturnType; - apiErrorDetail: string | undefined; -} - -/** - * Send one data request and answer with the response it finally got. - * - * Three answers are handled here rather than handed back: - * - 429: the request waits (see `GOOGLE_HEALTH_RATE_LIMIT`) and is sent - * again, up to `maxRetries` times, unless the caller's stop condition - * says to end the walk. - * - a server error or no answer at all (see - * `GOOGLE_HEALTH_TRANSIENT_RETRY`): the same, with its own count. - * - 401 with a token source: the token expired or was revoked between - * the source's last check and this request. The source is asked once for - * a fresh token and the request is repeated. A second 401, or a 401 on a - * fixed string token, is returned like any other answer. - * - * Every attempt is recorded as its own external call, so the wide event - * shows the throttling and the waits rather than only their outcome. - */ -async function googleHealthDataRequest( - token: GoogleHealthAccessToken, - label: string, - send: (accessToken: string) => Promise, - expectedFallbackNote: ( - status: number, - apiErrorDetail: string | undefined, - ) => string | undefined = () => undefined, -): Promise> { - let rateLimited = 0; - let transient = 0; - let refreshed = false; - let forceRefresh = false; - for (;;) { - const accessToken = - typeof token === "string" ? token : await token({ forceRefresh }); - forceRefresh = false; - const started = performance.now(); - let res: Response; - try { - res = await send(accessToken); - } catch (err) { - // No answer at all: the request timed out or the connection broke. - // On a walk of a thousand pages one of those is a matter of time, and - // it cost the whole walk. - const retry = - err instanceof SafeFetchError && - (err.kind === "timeout" || err.kind === "network") && - transient < GOOGLE_HEALTH_TRANSIENT_RETRY.maxRetries && - !stopRequested(); - const delayMs = - GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs * 2 ** transient; - getEvent()?.addExternalCall({ - service: "google-health", - method: label, - duration_ms: Math.round(performance.now() - started), - ...(retry - ? { note: `no_response_retrying_in_ms=${delayMs}` } - : { error: err instanceof Error ? err.message : String(err) }), - }); - if (!retry) throw err; - transient += 1; - await waitMs(delayMs); - continue; - } - const json = (await res.json().catch(() => null)) as T | null; - const verdict = classifyGoogleHealthResponse(res.status); - const apiErrorDetail = - verdict.classification === "success" - ? undefined - : extractGoogleApiErrorDetail(json); - - const retryAfterMs = - res.status === 429 ? googleHealthRetryDelayMs(res, json) : undefined; - const backoffMs = - res.status === 429 - ? (retryAfterMs ?? - GOOGLE_HEALTH_RATE_LIMIT.baseDelayMs * 2 ** rateLimited) - : 0; - const willWait = - res.status === 429 && - rateLimited < GOOGLE_HEALTH_RATE_LIMIT.maxRetries && - backoffMs <= GOOGLE_HEALTH_RATE_LIMIT.maxDelayMs && - !stopRequested(); - const willRefresh = - res.status === 401 && typeof token !== "string" && !refreshed; - const transientDelayMs = GOOGLE_HEALTH_TRANSIENT_STATUSES.has(res.status) - ? Math.min( - googleHealthRetryDelayMs(res, json) ?? - GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs * 2 ** transient, - GOOGLE_HEALTH_RATE_LIMIT.maxDelayMs, - ) - : 0; - const willRetryTransient = - GOOGLE_HEALTH_TRANSIENT_STATUSES.has(res.status) && - transient < GOOGLE_HEALTH_TRANSIENT_RETRY.maxRetries && - !stopRequested(); - - getEvent()?.addExternalCall({ - service: "google-health", - method: label, - duration_ms: Math.round(performance.now() - started), - status: res.status, - ...(expectedFallbackNote(res.status, apiErrorDetail) - ? { note: expectedFallbackNote(res.status, apiErrorDetail) } - : willWait - ? { note: `rate_limited_retrying_in_ms=${backoffMs}` } - : willRefresh - ? { note: "token_rejected_retrying_with_fresh_token" } - : willRetryTransient - ? { note: `server_error_retrying_in_ms=${transientDelayMs}` } - : { - error: - verdict.classification === "success" - ? undefined - : apiErrorDetail - ? `${verdict.reason} ${apiErrorDetail}` - : verdict.reason, - }), - }); - - if (willWait) { - rateLimited += 1; - await waitMs(backoffMs); - continue; - } - if (willRetryTransient) { - transient += 1; - await waitMs(transientDelayMs); - continue; - } - if (willRefresh) { - refreshed = true; - forceRefresh = true; - continue; - } - return { status: res.status, json, verdict, apiErrorDetail }; - } -} - -/** - * Walk every `DataPoint` for one data type since the incremental cursor via - * `dataPoints.list` with `nextPageToken` pagination. The data-type id is - * kebab-cased in the path; the `filter` predicate is built per-shape against - * the type's time anchor. `rollup` types refuse — they read via - * `fetchDailyRollUp`. - * - * DAILY-SUMMARY FILTER FALLBACK: the docs contradict themselves on the `.date` - * filter's type-name prefix (the index's "filter parameter" column says - * snake_case; the list reference's only worked example says camelCase — see - * `GoogleHealthDateFilterStyle`). The walk sends the camelCase worked-example - * form first; if the VERY FIRST request of the walk is rejected with a 400, - * the whole walk retries once with the snake_case form. The style that - * succeeded is annotated as `googleHealth.dateFilter.style` so live behaviour - * reports which grammar Google actually accepts. Any other failure, or a 400 - * past the first request, propagates. - */ -export async function fetchDataPoints( - dataType: GoogleHealthDataType, - accessToken: GoogleHealthAccessToken, - verb: string, - query: DataPointQuery = {}, -): Promise { - const points: GoogleHealthDataPoint[] = []; - await forEachDataPointPage(dataType, accessToken, verb, query, (page) => { - for (const p of page) points.push(p); - }); - return points; -} - -/** - * The same walk as `fetchDataPoints`, handing each page to `onPage` as it - * arrives instead of collecting the whole collection first. - * - * A dense collection has to be read this way. Intraday heart rate is one - * point a minute, so a full-history backfill of two years is over a million - * points; held as parsed JSON and then again as mapped readings, that is more - * than a 1 GB heap, and the process that runs the backfill also serves the - * app. Page by page, only one page is ever resident. - * - * `onPage` is awaited before the next page is requested, so a slow write - * applies back-pressure to the fetch. The date-filter fallback still works: - * it only fires when the first request is rejected, which is before any page - * reaches `onPage`. - * - * `onPage` answers `"end"` when the caller has read as far as it wants (a - * backfill that reached its horizon): the walk ends there as complete, not - * truncated. - * - * A walk over spot readings (`timeField: "sample"`) also watches whether it - * is getting anywhere: `GOOGLE_HEALTH_NO_PROGRESS_PAGES` pages in a row that - * reach no further back than the pages before them end it, truncated, and - * tell `onNoProgress` (see `backfill-bounds.ts`). - */ -export async function forEachDataPointPage( - dataType: GoogleHealthDataType, - accessToken: GoogleHealthAccessToken, - verb: string, - query: DataPointQuery, - onPage: ( - points: GoogleHealthDataPoint[], - ) => void | "end" | Promise, -): Promise { - const maxPages = query.maxPages ?? GOOGLE_HEALTH_DEFAULT_MAX_PAGES; - const pageSize = query.pageSize ?? GOOGLE_HEALTH_PAGE_SIZE; - const resumable = query.resumable === true; - const edge = query.edge; - - let requestCount = 0; - - const walk = async ( - dateStyle: GoogleHealthDateFilterStyle, - edgeBy: "filter" | "skip", - resumeFrom: GoogleHealthResumePoint | undefined, - restarted: boolean, - ): Promise => { - let fetched = 0; - let pageToken: string | null | undefined = resumeFrom?.token; - // The token that fetched the page last read, and that page's position. - let lastToken: string | undefined; - let lastPosition = -1; - let pageCount = 0; - let stopped = false; - // Reading without the edge bound: the pages before the edge are passed - // over, not written, until the first sample at or beyond it. - const skipTo = edge && edgeBy === "skip" ? edge : undefined; - let crossed = skipTo === undefined; - // Pages passed over in this walk. Never reset: the walk's page ceiling - // counts the pages it wrote, `pageCount - skipped`, before the crossing - // and after it alike. - let skipped = 0; - const startPosition = resumeFrom?.page ?? 0; - const skipStartedAt = skipTo ? sliceNow() : 0; - // Whether the walk is still reaching further back. A walk filtered to - // the edge starts from it; one passing over the samples beyond the edge - // starts from nothing, since those pages move toward the edge too. - const progress = - dataType.timeField === "sample" - ? googleHealthWalkProgress(edge && edgeBy === "filter" ? edge : null) - : null; - const progressTz = edge?.tz ?? query.tz; - let noProgress = false; - const sampleAt = (point: GoogleHealthDataPoint): number | null => - googleHealthSampleInstant(point, dataType, skipTo?.tz)?.getTime() ?? null; - const beyondEdge = (point: GoogleHealthDataPoint): boolean | null => { - const t = sampleAt(point); - if (t === null || !skipTo) return null; - return skipTo.order === "desc" - ? t > skipTo.at.getTime() - : t < skipTo.at.getTime(); - }; - const reachesEdge = (point: GoogleHealthDataPoint): boolean => { - const t = sampleAt(point); - if (t === null || !skipTo) return false; - return skipTo.order === "desc" - ? t >= skipTo.at.getTime() - : t <= skipTo.at.getTime(); - }; - - do { - // A skip that had to start over from the newest page runs as the walk - // did before it kept its place: on the job's budget alone, its time - // given back to the slice once it crosses. Every other read stops on - // the slice's budget, and a skip also on its own shorter limit; it - // keeps its place below, so the next slice goes on from there. - const untimed = !crossed && restarted; - if (untimed ? stopRequested(false) : stopRequested(resumable)) { - stopped = true; - break; - } - if ( - !crossed && - !untimed && - resumable && - inSlice() && - sliceNow() - skipStartedAt >= GOOGLE_HEALTH_SKIP_MS - ) { - stopped = true; - break; - } - const params = new URLSearchParams({ pageSize: String(pageSize) }); - const filters: string[] = []; - if (query.start) { - const { field, bound } = incrementalFilter( - dataType, - query.start, - query.tz, - dateStyle, - ); - filters.push(`${field} >= "${bound}"`); - } - const edgeFiltered = edge !== undefined && edgeBy === "filter"; - if (edgeFiltered) { - const { field, bound } = incrementalFilter( - dataType, - edge.at, - query.tz, - dateStyle, - ); - filters.push( - `${field} ${edge.order === "desc" ? "<=" : ">="} "${bound}"`, - ); - } - if (filters.length > 0) params.set("filter", filters.join(" AND ")); - if (pageToken) params.set("pageToken", pageToken); - - requestCount += 1; - // v1.42 (#1023) — the first filtered daily-summary read answering 400 - // is the expected trigger for the snake_case retry below, not a - // failure: some accounts reject the documented camelCase prefix on - // some types, and the very next request answers 200. It was logged as - // an error next to the 200 that followed it. It is recorded as a note - // now; any other 400 still carries its error. The same holds for the - // first resumed request refusing the edge bound, and for a saved - // place in the unbounded list Google no longer takes. - const firstRequest = requestCount === 1; - const firstOfWalk = pageCount === 0; - const edgeRefused = (status: number, detail: string | undefined) => - edgeFiltered && - firstOfWalk && - status === 400 && - refusesEdgeFilter(detail); - const resumeRefused = (status: number) => - resumeFrom !== undefined && firstOfWalk && status === 400; - const requestToken = pageToken ?? undefined; - const { status, json, verdict, apiErrorDetail } = - await googleHealthDataRequest( - accessToken, - `${verb}(page=${pageCount})`, - (token) => - safeFetch( - `${GOOGLE_HEALTH_API_BASE}/users/me/dataTypes/${dataType.path}/dataPoints?${params}`, - { - method: "GET", - headers: { Authorization: `Bearer ${token}` }, - }, - ), - (status, detail) => - canFallBack && - dateStyle === "camel" && - firstRequest && - status === 400 - ? "date_filter_rejected_retrying_snake_case" - : edgeRefused(status, detail) - ? "edge_filter_rejected_reading_without_it" - : resumeRefused(status) - ? "resume_token_rejected_reading_from_newest" - : undefined, - ); - if (edgeRefused(status, apiErrorDetail)) { - throw new EdgeFilterRefused(); - } - if (resumeRefused(status)) throw new ResumePointLost(); - // A rate limit the request could not wait out ends a resumable walk - // inside a backfill slice where it is: the pages before it are written - // and their edge recorded, so this is a pause, not a failure. The slice - // ends with it, since every other walk would meet the same quota. - if (status === 429 && resumable && inSlice()) { - endSlice("rate_limited"); - stopped = true; - break; - } - if (verdict.classification !== "success") { - throw new GoogleHealthApiError({ - verb, - classification: verdict.classification, - httpStatus: verdict.httpStatus, - reason: verdict.reason, - upstreamError: apiErrorDetail, - }); - } - - let page = json?.dataPoints ?? []; - // A saved place has to still hold a sample at or beyond the edge; - // past it, samples nobody wrote could lie behind the token. - if (resumeFrom && firstOfWalk && !page.some(reachesEdge)) { - throw new ResumePointLost(); - } - if (requestToken !== undefined) { - lastToken = requestToken; - lastPosition = startPosition + pageCount; - } - pageToken = json?.nextPageToken ?? null; - pageCount += 1; - fetched += page.length; - noteSlicePage(); - if ( - progress && - noteGoogleHealthWalkPage( - progress, - page.map( - (p) => - googleHealthSampleInstant(p, dataType, progressTz)?.getTime() ?? - null, - ), - ) - ) { - getEvent()?.addWarning( - `google-health ${verb}: ${GOOGLE_HEALTH_NO_PROGRESS_PAGES} pages in a row reached no further back than ${progress.furthest === null ? "the first" : new Date(progress.furthest).toISOString()}; walk stopped`, - ); - noProgress = true; - stopped = true; - break; - } - if (!crossed) { - // Placed samples beyond the edge were written by an earlier slice; - // one without a sample time cannot be placed and is passed over - // with them until the walk is past the edge. - const kept = page.filter((p) => beyondEdge(p) === false); - if (kept.length === 0) { - skipped += 1; - noteSliceSkip(); - continue; - } - crossed = true; - if (restarted) extendSliceDeadline(sliceNow() - skipStartedAt); - page = page.filter((p) => beyondEdge(p) !== true); - } - if ((await onPage(page)) === "end") { - pageToken = null; - break; - } - } while (pageToken && pageCount - skipped < maxPages); - - const truncated = stopped || Boolean(pageToken); - // Keep the place for the next slice. Only a walk without the bound has - // one, and only one that ended short needs it. - if (skipTo && truncated && lastToken !== undefined) { - await skipTo.onResumePoint?.({ token: lastToken, page: lastPosition }); - } - notePagination(pageCount, fetched, truncated); - if (noProgress) await query.onNoProgress?.(); - }; - - // The fallback only exists for filtered daily-summary reads — every other - // shape has one documented, doc-consistent filter field. - const canFallBack = - dataType.timeField === "date" && query.start !== undefined; - - let style: GoogleHealthDateFilterStyle = "camel"; - let edgeBy: "filter" | "skip" = edge?.filter === false ? "skip" : "filter"; - // From the saved place in the unbounded list when there is one; once from - // the newest page when that place is lost. - const walkSkipping = async (dateStyle: GoogleHealthDateFilterStyle) => { - const resumeFrom = edge?.resumeFrom; - if (!resumeFrom) return walk(dateStyle, "skip", undefined, false); - try { - await walk(dateStyle, "skip", resumeFrom, false); - } catch (err) { - if (!(err instanceof ResumePointLost)) throw err; - await walk(dateStyle, "skip", undefined, true); - } - }; - const run = async (dateStyle: GoogleHealthDateFilterStyle) => { - if (edgeBy === "skip") return walkSkipping(dateStyle); - try { - await walk(dateStyle, edgeBy, undefined, false); - } catch (err) { - if (!(err instanceof EdgeFilterRefused)) throw err; - // Once: the walk without the bound sends no edge filter to refuse. - edgeBy = "skip"; - await edge?.onFilterUnsupported?.(); - await walk(dateStyle, edgeBy, undefined, false); - } - }; - try { - await run("camel"); - } catch (err) { - const firstRequestRejected = - canFallBack && - requestCount === 1 && - err instanceof GoogleHealthApiError && - err.httpStatus === 400; - if (!firstRequestRejected) throw err; - style = "snake"; - await run("snake"); - } - if (canFallBack) { - annotate({ meta: { "googleHealth.dateFilter.style": style } }); - query.onDateFilterStyle?.(style); - } -} - -/** - * Thrown inside a walk whose first request Google refused because of the - * resume edge's bound; `forEachDataPointPage` reads the collection again - * without it. - */ -class EdgeFilterRefused extends Error { - constructor() { - super("google-health: edge filter refused"); - } -} - -/** - * Thrown inside a walk that started from a saved place in the unbounded list - * (`GoogleHealthResumePoint`) when Google refused the token or the page it - * returned no longer reaches the edge; the walk starts over from the newest - * page. - */ -class ResumePointLost extends Error { - constructor() { - super("google-health: saved place in the list lost"); - } -} - -/** - * Whether a 400 on the first resumed request is Google refusing the edge - * bound, rather than something else about the request. The request differs - * from one Google answers every hour only by that bound, so a bare 400 or an - * INVALID_ARGUMENT counts, and so does any message naming the filter; one - * naming the page token, the page size or the access token does not. - */ -export function refusesEdgeFilter(detail: string | undefined): boolean { - if (detail === undefined) return true; - if (/filter/i.test(detail)) return true; - if (/token|page.?size/i.test(detail)) return false; - return /INVALID_ARGUMENT/.test(detail); -} - // ─── Daily roll-up reads (`POST …/dataPoints:dailyRollUp`) ───────────────── /** diff --git a/src/lib/google-health/credentials.ts b/src/lib/google-health/credentials.ts index c39a6a4b8..58020d3ab 100644 --- a/src/lib/google-health/credentials.ts +++ b/src/lib/google-health/credentials.ts @@ -10,7 +10,7 @@ */ import { prisma } from "@/lib/db"; import { decrypt } from "@/lib/crypto"; -import type { GoogleHealthCredentials } from "./client"; +import type { GoogleHealthCredentials } from "./oauth"; /** * Fetch and decrypt the user's Google Health API credentials. diff --git a/src/lib/google-health/data-points.ts b/src/lib/google-health/data-points.ts new file mode 100644 index 000000000..5b095c01b --- /dev/null +++ b/src/lib/google-health/data-points.ts @@ -0,0 +1,565 @@ +/** + * The paginated `dataPoints.list` walker: page by page under the budget in + * scope (`./budget.ts`), resumable from a saved place, falling back when + * Google refuses an edge filter, and the collecting `fetchDataPoints` built + * on it. + */ +import { annotate, getEvent } from "@/lib/logging/context"; +import { safeFetch } from "@/lib/safe-fetch"; +import { GoogleHealthApiError } from "./response-classifier"; +import { + googleHealthSampleInstant, + type GoogleHealthDataPoint, + type GoogleHealthDataType, +} from "./mappers"; +import { + googleHealthWalkProgress, + noteGoogleHealthWalkPage, +} from "./backfill-bounds"; +import { + notePagination, + stopRequested, + noteSlicePage, + noteSliceSkip, + extendSliceDeadline, + sliceNow, + endSlice, + inSlice, +} from "./budget"; +import { + GoogleHealthDateFilterStyle, + incrementalFilter, + GoogleHealthAccessToken, + googleHealthDataRequest, +} from "./request"; +import { GOOGLE_HEALTH_API_BASE } from "./request"; +import { GOOGLE_HEALTH_NO_PROGRESS_PAGES } from "./backfill-bounds"; + +// ─── Data-point reads (Google Health `dataPoints.list` / `:dailyRollUp`) ────── +// +// The Google Health API exposes one uniform `DataPoint` resource shape across +// every data type. Spot / daily-summary / session types are read through +// `GET /v4/users/me/dataTypes/{dataType}/dataPoints` with `nextPageToken` +// pagination; the cumulative activity types (steps / distance / active energy / +// floors) are read through `POST …/dataPoints:dailyRollUp` — their `list` +// surface returns minute-grain observation buckets (or, for floors, does not +// exist at all), so the daily totals MUST come from the rollup. +// +// Casing gotcha (three encodings per type, all pinned in +// `GOOGLE_HEALTH_DATA_TYPES` so a fetcher can never mix them up): +// - request path: kebab-case (`body-fat`) +// - `filter` predicate: snake_case (`body_fat.sample_time.physical_time`) — +// EXCEPT daily-summary `.date` filters, where the docs contradict +// themselves and the worked example uses the camelCase payload key; the +// client sends camel first and falls back to snake on a first-page 400 +// (see `GoogleHealthDateFilterStyle`). +// - response payload: camelCase — the `DataPoint` value is a union keyed by +// the camelCase type name (`bodyFat`, `dailyRestingHeartRate`, …) with +// camelCase nested objects (`sampleTime.physicalTime`, +// `interval.startTime`, `civilStartTime.date`). +// + +// proto3 int64 fields arrive as JSON **strings** (`"12345"`) — every numeric +// extractor coerces numeric strings before the finite check. + +/** + * Page-size ceiling for `dataPoints.list`. The daily/intraday reads default to + * 1440 (one-per-minute) and cap at 10 000; sleep/exercise cap at 25. The launch + * metrics use the daily/spot reads, so the default page size is the larger. + */ +export const GOOGLE_HEALTH_PAGE_SIZE = 1000; + +/** Sleep/exercise read cap — matches the Google Health 25-cap for those types. */ +export const GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE = 25; + +/** + * Page ceiling for one intraday heart-rate walk. + * + * Per walk, never across walks: a backfill reads at most a slice's pages per + * walk and keeps its place between slices, and an hourly sync reads a few + * pages. What stops a walk that keeps going without getting anywhere is the + * progress guard (`GOOGLE_HEALTH_NO_PROGRESS_PAGES`), and what bounds a + * backfill that does get somewhere is its horizon in years (see + * `backfill-bounds.ts`). The walk is paged (`forEachDataPointPage`), so the + * ceiling costs time, not memory. + */ +export const GOOGLE_HEALTH_DENSE_MAX_PAGES = 10_000; + +/** Page ceiling for every other list walk. */ +export const GOOGLE_HEALTH_DEFAULT_MAX_PAGES = 1000; + +/** `dataPoints.list` envelope: `{ dataPoints, nextPageToken }`. */ +interface GoogleHealthDataPointPage { + dataPoints?: GoogleHealthDataPoint[]; + nextPageToken?: string | null; +} + +interface DataPointQuery { + /** Lower-bound incremental cursor; omitted on a full backfill. */ + start?: Date; + /** Page size (defaults to `GOOGLE_HEALTH_PAGE_SIZE`). */ + pageSize?: number; + /** Hard ceiling on pages walked (defence against a runaway cursor). */ + maxPages?: number; + /** + * The user's IANA zone — needed only by the `civilStart` filter (the + * offset-less civil bound must be the watermark's wall clock in the USER'S + * zone). Omitted → the bound forms in UTC. + */ + tz?: string; + /** + * Observes which daily-summary `.date` filter prefix style the walk settled + * on (`camel` worked-example vs `snake` fallback) — the structure probe + * surfaces it so a live account reports which grammar Google accepted. + */ + onDateFilterStyle?: (style: GoogleHealthDateFilterStyle) => void; + /** + * The walk keeps its place across attempts (a backfill's resume cursor), so + * it may stop on the backfill slice's budget and on a rate limit it cannot + * wait out, and report itself truncated instead of failing: the next slice + * picks up from the edge it recorded. + */ + resumable?: boolean; + /** + * Where an earlier attempt of this walk got to: the oldest sample it wrote + * when the walk ran newest first (`desc`), the newest when it ran oldest + * first (`asc`). The walk asks only for the samples at or beyond it; the + * one sample on the edge itself is read again and overwrites in place. + * + * `filter: false` reads without the edge bound and passes over the samples + * beyond the edge here instead (see `forEachDataPointPage`); the walk falls + * back to that on its own when Google refuses the bound, and calls + * `onFilterUnsupported` once when it does. `tz` reads an offset-less sample + * time the way the edge was recorded. Passing over has no page bound of + * its own: the pages passed over move toward the edge, and a walk whose + * pages stop moving is stopped by the progress guard like any other. + * + * A walk without the bound keeps its place in that list across slices: + * `resumeFrom` is where an earlier slice's walk stopped, and + * `onResumePoint` is told where this one stops (see + * `GoogleHealthResumePoint`). + */ + edge?: { + at: Date; + order: "desc" | "asc"; + filter?: boolean; + tz?: string; + onFilterUnsupported?: () => void | Promise; + resumeFrom?: GoogleHealthResumePoint; + onResumePoint?: (point: GoogleHealthResumePoint) => void | Promise; + }; + /** + * Told once when the walk is stopped for going + * `GOOGLE_HEALTH_NO_PROGRESS_PAGES` pages in a row without reaching any + * further back (see `backfill-bounds.ts`). The walk then reports itself + * truncated, as it does on its page ceiling. + */ + onNoProgress?: () => void | Promise; +} + +/** + * Where a walk without the edge bound stopped in the unbounded list: the page + * token that fetched the last page it read, and that page's position (pages + * from the newest one, which is position 0). The next slice starts by reading + * that page again rather than the newest one, so it passes over only what is + * left between it and the edge instead of everything earlier slices wrote. + * + * Re-reading the last page, not the one after it, is what lets the walk check + * the token still points where it did: the page must hold a sample at or + * beyond the edge. A list that lost samples in between could otherwise move + * the token past samples nobody wrote. A page that fails the check, or a + * token Google refuses, sends the walk back to the newest page, the way it + * read before it kept its place. + */ +export interface GoogleHealthResumePoint { + token: string; + page: number; +} + +/** + * The longest a walk passes over samples beyond its edge in one slice before + * it stops and keeps its place for the next. Shorter than the slice, so a + * slice that has to skip still writes, and never longer than it: a skip runs + * on the slice's clock and the account's lock is held no longer than a slice. + */ +export const GOOGLE_HEALTH_SKIP_MS = 3 * 60 * 1000; + +/** + * Walk every `DataPoint` for one data type since the incremental cursor via + * `dataPoints.list` with `nextPageToken` pagination. The data-type id is + * kebab-cased in the path; the `filter` predicate is built per-shape against + * the type's time anchor. `rollup` types refuse — they read via + * `fetchDailyRollUp`. + * + * DAILY-SUMMARY FILTER FALLBACK: the docs contradict themselves on the `.date` + * filter's type-name prefix (the index's "filter parameter" column says + * snake_case; the list reference's only worked example says camelCase — see + * `GoogleHealthDateFilterStyle`). The walk sends the camelCase worked-example + * form first; if the VERY FIRST request of the walk is rejected with a 400, + * the whole walk retries once with the snake_case form. The style that + * succeeded is annotated as `googleHealth.dateFilter.style` so live behaviour + * reports which grammar Google actually accepts. Any other failure, or a 400 + * past the first request, propagates. + */ +export async function fetchDataPoints( + dataType: GoogleHealthDataType, + accessToken: GoogleHealthAccessToken, + verb: string, + query: DataPointQuery = {}, +): Promise { + const points: GoogleHealthDataPoint[] = []; + await forEachDataPointPage(dataType, accessToken, verb, query, (page) => { + for (const p of page) points.push(p); + }); + return points; +} + +/** + * The same walk as `fetchDataPoints`, handing each page to `onPage` as it + * arrives instead of collecting the whole collection first. + * + * A dense collection has to be read this way. Intraday heart rate is one + * point a minute, so a full-history backfill of two years is over a million + * points; held as parsed JSON and then again as mapped readings, that is more + * than a 1 GB heap, and the process that runs the backfill also serves the + * app. Page by page, only one page is ever resident. + * + * `onPage` is awaited before the next page is requested, so a slow write + * applies back-pressure to the fetch. The date-filter fallback still works: + * it only fires when the first request is rejected, which is before any page + * reaches `onPage`. + * + * `onPage` answers `"end"` when the caller has read as far as it wants (a + * backfill that reached its horizon): the walk ends there as complete, not + * truncated. + * + * A walk over spot readings (`timeField: "sample"`) also watches whether it + * is getting anywhere: `GOOGLE_HEALTH_NO_PROGRESS_PAGES` pages in a row that + * reach no further back than the pages before them end it, truncated, and + * tell `onNoProgress` (see `backfill-bounds.ts`). + */ +export async function forEachDataPointPage( + dataType: GoogleHealthDataType, + accessToken: GoogleHealthAccessToken, + verb: string, + query: DataPointQuery, + onPage: ( + points: GoogleHealthDataPoint[], + ) => void | "end" | Promise, +): Promise { + const maxPages = query.maxPages ?? GOOGLE_HEALTH_DEFAULT_MAX_PAGES; + const pageSize = query.pageSize ?? GOOGLE_HEALTH_PAGE_SIZE; + const resumable = query.resumable === true; + const edge = query.edge; + + let requestCount = 0; + + const walk = async ( + dateStyle: GoogleHealthDateFilterStyle, + edgeBy: "filter" | "skip", + resumeFrom: GoogleHealthResumePoint | undefined, + restarted: boolean, + ): Promise => { + let fetched = 0; + let pageToken: string | null | undefined = resumeFrom?.token; + // The token that fetched the page last read, and that page's position. + let lastToken: string | undefined; + let lastPosition = -1; + let pageCount = 0; + let stopped = false; + // Reading without the edge bound: the pages before the edge are passed + // over, not written, until the first sample at or beyond it. + const skipTo = edge && edgeBy === "skip" ? edge : undefined; + let crossed = skipTo === undefined; + // Pages passed over in this walk. Never reset: the walk's page ceiling + // counts the pages it wrote, `pageCount - skipped`, before the crossing + // and after it alike. + let skipped = 0; + const startPosition = resumeFrom?.page ?? 0; + const skipStartedAt = skipTo ? sliceNow() : 0; + // Whether the walk is still reaching further back. A walk filtered to + // the edge starts from it; one passing over the samples beyond the edge + // starts from nothing, since those pages move toward the edge too. + const progress = + dataType.timeField === "sample" + ? googleHealthWalkProgress(edge && edgeBy === "filter" ? edge : null) + : null; + const progressTz = edge?.tz ?? query.tz; + let noProgress = false; + const sampleAt = (point: GoogleHealthDataPoint): number | null => + googleHealthSampleInstant(point, dataType, skipTo?.tz)?.getTime() ?? null; + const beyondEdge = (point: GoogleHealthDataPoint): boolean | null => { + const t = sampleAt(point); + if (t === null || !skipTo) return null; + return skipTo.order === "desc" + ? t > skipTo.at.getTime() + : t < skipTo.at.getTime(); + }; + const reachesEdge = (point: GoogleHealthDataPoint): boolean => { + const t = sampleAt(point); + if (t === null || !skipTo) return false; + return skipTo.order === "desc" + ? t >= skipTo.at.getTime() + : t <= skipTo.at.getTime(); + }; + + do { + // A skip that had to start over from the newest page runs as the walk + // did before it kept its place: on the job's budget alone, its time + // given back to the slice once it crosses. Every other read stops on + // the slice's budget, and a skip also on its own shorter limit; it + // keeps its place below, so the next slice goes on from there. + const untimed = !crossed && restarted; + if (untimed ? stopRequested(false) : stopRequested(resumable)) { + stopped = true; + break; + } + if ( + !crossed && + !untimed && + resumable && + inSlice() && + sliceNow() - skipStartedAt >= GOOGLE_HEALTH_SKIP_MS + ) { + stopped = true; + break; + } + const params = new URLSearchParams({ pageSize: String(pageSize) }); + const filters: string[] = []; + if (query.start) { + const { field, bound } = incrementalFilter( + dataType, + query.start, + query.tz, + dateStyle, + ); + filters.push(`${field} >= "${bound}"`); + } + const edgeFiltered = edge !== undefined && edgeBy === "filter"; + if (edgeFiltered) { + const { field, bound } = incrementalFilter( + dataType, + edge.at, + query.tz, + dateStyle, + ); + filters.push( + `${field} ${edge.order === "desc" ? "<=" : ">="} "${bound}"`, + ); + } + if (filters.length > 0) params.set("filter", filters.join(" AND ")); + if (pageToken) params.set("pageToken", pageToken); + + requestCount += 1; + // The first filtered daily-summary read answering 400 + // is the expected trigger for the snake_case retry below, not a + // failure: some accounts reject the documented camelCase prefix on + // some types, and the very next request answers 200. It was logged as + // an error next to the 200 that followed it. It is recorded as a note + // now; any other 400 still carries its error. The same holds for the + // first resumed request refusing the edge bound, and for a saved + // place in the unbounded list Google no longer takes. + const firstRequest = requestCount === 1; + const firstOfWalk = pageCount === 0; + const edgeRefused = (status: number, detail: string | undefined) => + edgeFiltered && + firstOfWalk && + status === 400 && + refusesEdgeFilter(detail); + const resumeRefused = (status: number) => + resumeFrom !== undefined && firstOfWalk && status === 400; + const requestToken = pageToken ?? undefined; + const { status, json, verdict, apiErrorDetail } = + await googleHealthDataRequest( + accessToken, + `${verb}(page=${pageCount})`, + (token) => + safeFetch( + `${GOOGLE_HEALTH_API_BASE}/users/me/dataTypes/${dataType.path}/dataPoints?${params}`, + { + method: "GET", + headers: { Authorization: `Bearer ${token}` }, + }, + ), + (status, detail) => + canFallBack && + dateStyle === "camel" && + firstRequest && + status === 400 + ? "date_filter_rejected_retrying_snake_case" + : edgeRefused(status, detail) + ? "edge_filter_rejected_reading_without_it" + : resumeRefused(status) + ? "resume_token_rejected_reading_from_newest" + : undefined, + ); + if (edgeRefused(status, apiErrorDetail)) { + throw new EdgeFilterRefused(); + } + if (resumeRefused(status)) throw new ResumePointLost(); + // A rate limit the request could not wait out ends a resumable walk + // inside a backfill slice where it is: the pages before it are written + // and their edge recorded, so this is a pause, not a failure. The slice + // ends with it, since every other walk would meet the same quota. + if (status === 429 && resumable && inSlice()) { + endSlice("rate_limited"); + stopped = true; + break; + } + if (verdict.classification !== "success") { + throw new GoogleHealthApiError({ + verb, + classification: verdict.classification, + httpStatus: verdict.httpStatus, + reason: verdict.reason, + upstreamError: apiErrorDetail, + }); + } + + let page = json?.dataPoints ?? []; + // A saved place has to still hold a sample at or beyond the edge; + // past it, samples nobody wrote could lie behind the token. + if (resumeFrom && firstOfWalk && !page.some(reachesEdge)) { + throw new ResumePointLost(); + } + if (requestToken !== undefined) { + lastToken = requestToken; + lastPosition = startPosition + pageCount; + } + pageToken = json?.nextPageToken ?? null; + pageCount += 1; + fetched += page.length; + noteSlicePage(); + if ( + progress && + noteGoogleHealthWalkPage( + progress, + page.map( + (p) => + googleHealthSampleInstant(p, dataType, progressTz)?.getTime() ?? + null, + ), + ) + ) { + getEvent()?.addWarning( + `google-health ${verb}: ${GOOGLE_HEALTH_NO_PROGRESS_PAGES} pages in a row reached no further back than ${progress.furthest === null ? "the first" : new Date(progress.furthest).toISOString()}; walk stopped`, + ); + noProgress = true; + stopped = true; + break; + } + if (!crossed) { + // Placed samples beyond the edge were written by an earlier slice; + // one without a sample time cannot be placed and is passed over + // with them until the walk is past the edge. + const kept = page.filter((p) => beyondEdge(p) === false); + if (kept.length === 0) { + skipped += 1; + noteSliceSkip(); + continue; + } + crossed = true; + if (restarted) extendSliceDeadline(sliceNow() - skipStartedAt); + page = page.filter((p) => beyondEdge(p) !== true); + } + if ((await onPage(page)) === "end") { + pageToken = null; + break; + } + } while (pageToken && pageCount - skipped < maxPages); + + const truncated = stopped || Boolean(pageToken); + // Keep the place for the next slice. Only a walk without the bound has + // one, and only one that ended short needs it. + if (skipTo && truncated && lastToken !== undefined) { + await skipTo.onResumePoint?.({ token: lastToken, page: lastPosition }); + } + notePagination(pageCount, fetched, truncated); + if (noProgress) await query.onNoProgress?.(); + }; + + // The fallback only exists for filtered daily-summary reads — every other + // shape has one documented, doc-consistent filter field. + const canFallBack = + dataType.timeField === "date" && query.start !== undefined; + + let style: GoogleHealthDateFilterStyle = "camel"; + let edgeBy: "filter" | "skip" = edge?.filter === false ? "skip" : "filter"; + // From the saved place in the unbounded list when there is one; once from + // the newest page when that place is lost. + const walkSkipping = async (dateStyle: GoogleHealthDateFilterStyle) => { + const resumeFrom = edge?.resumeFrom; + if (!resumeFrom) return walk(dateStyle, "skip", undefined, false); + try { + await walk(dateStyle, "skip", resumeFrom, false); + } catch (err) { + if (!(err instanceof ResumePointLost)) throw err; + await walk(dateStyle, "skip", undefined, true); + } + }; + const run = async (dateStyle: GoogleHealthDateFilterStyle) => { + if (edgeBy === "skip") return walkSkipping(dateStyle); + try { + await walk(dateStyle, edgeBy, undefined, false); + } catch (err) { + if (!(err instanceof EdgeFilterRefused)) throw err; + // Once: the walk without the bound sends no edge filter to refuse. + edgeBy = "skip"; + await edge?.onFilterUnsupported?.(); + await walk(dateStyle, edgeBy, undefined, false); + } + }; + try { + await run("camel"); + } catch (err) { + const firstRequestRejected = + canFallBack && + requestCount === 1 && + err instanceof GoogleHealthApiError && + err.httpStatus === 400; + if (!firstRequestRejected) throw err; + style = "snake"; + await run("snake"); + } + if (canFallBack) { + annotate({ meta: { "googleHealth.dateFilter.style": style } }); + query.onDateFilterStyle?.(style); + } +} + +/** + * Thrown inside a walk whose first request Google refused because of the + * resume edge's bound; `forEachDataPointPage` reads the collection again + * without it. + */ +class EdgeFilterRefused extends Error { + constructor() { + super("google-health: edge filter refused"); + } +} + +/** + * Thrown inside a walk that started from a saved place in the unbounded list + * (`GoogleHealthResumePoint`) when Google refused the token or the page it + * returned no longer reaches the edge; the walk starts over from the newest + * page. + */ +class ResumePointLost extends Error { + constructor() { + super("google-health: saved place in the list lost"); + } +} + +/** + * Whether a 400 on the first resumed request is Google refusing the edge + * bound, rather than something else about the request. The request differs + * from one Google answers every hour only by that bound, so a bare 400 or an + * INVALID_ARGUMENT counts, and so does any message naming the filter; one + * naming the page token, the page size or the access token does not. + */ +export function refusesEdgeFilter(detail: string | undefined): boolean { + if (detail === undefined) return true; + if (/filter/i.test(detail)) return true; + if (/token|page.?size/i.test(detail)) return false; + return /INVALID_ARGUMENT/.test(detail); +} diff --git a/src/lib/google-health/oauth-descriptor.ts b/src/lib/google-health/oauth-descriptor.ts index 48d210e0e..045ba0ffc 100644 --- a/src/lib/google-health/oauth-descriptor.ts +++ b/src/lib/google-health/oauth-descriptor.ts @@ -7,7 +7,7 @@ * back without one, and the kit then keeps the stored refresh token. */ import type { OAuthTokenDescriptor } from "@/lib/integrations/oauth-kit/token"; -import { refreshAccessToken, type GoogleHealthCredentials } from "./client"; +import { type GoogleHealthCredentials, googleHealthOAuth } from "./oauth"; import { getUserGoogleHealthCredentials } from "./credentials"; /** Refresh this long before the stored expiry. */ @@ -43,5 +43,5 @@ export const googleHealthTokenDescriptor: OAuthTokenDescriptor getUserGoogleHealthCredentials(userId), refresh: (refreshToken, credentials) => - refreshAccessToken(refreshToken, credentials), + googleHealthOAuth.refresh(refreshToken, credentials), }; diff --git a/src/lib/google-health/oauth.ts b/src/lib/google-health/oauth.ts new file mode 100644 index 000000000..1d89ffabd --- /dev/null +++ b/src/lib/google-health/oauth.ts @@ -0,0 +1,105 @@ +/** + * The Google Health OAuth client: consent URL, code exchange and refresh, + * over the shared factory (`integrations/oauth-kit/client.ts`). + * + * - Access tokens live an hour. Refresh tokens do not rotate: a routine + * refresh returns a new access token without a refresh token, and the + * token lifecycle keeps the stored one. They expire after six months of + * disuse, or after seven days while the consent screen is in "Testing". + * - A revoked or expired refresh token answers `invalid_grant` (a 400 or a + * 401); it is lifted onto `reauth_required` so the connection asks for a + * reconnect instead of failing hard. + * - S256 PKCE, and the confidential client's secret by HTTP Basic beside it + * (RFC 6749 2.3.1), with the client id in the form as well. + * - `access_type=offline` and `prompt=consent` are what reliably return a + * refresh token, a new one on every re-consent. + */ +import { + defineOAuthClient, + oauthErrorCode, + type OAuthClientCredentials, +} from "@/lib/integrations/oauth-kit/client"; +import { checkedRedirectUri } from "@/lib/integrations/oauth-kit/redirect-uri"; +import { classifyHttpStatus } from "@/lib/integrations/http-status-classifier"; + +import { GoogleHealthApiError } from "./response-classifier"; + +export type GoogleHealthCredentials = OAuthClientCredentials; + +/** + * The four core Restricted read scopes. Every one is Restricted, so the + * operator's OAuth client needs Google verification and a yearly CASA + * assessment before it leaves "Testing" (staying in Testing with at most 100 + * test users avoids CASA at the cost of a 7-day refresh-token expiry). + */ +export const GOOGLE_HEALTH_CORE_SCOPES = [ + "https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly", + "https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly", + "https://www.googleapis.com/auth/googlehealth.sleep.readonly", + "https://www.googleapis.com/auth/googlehealth.profile.readonly", +] as const; + +/** + * The scope list HealthLog requests: the four core bundles. ECG and IRN come + * with their reader, never before (Google penalises requesting a Restricted + * scope the app never consumes). + */ +export function resolveGoogleHealthScopes(): string[] { + return [...GOOGLE_HEALTH_CORE_SCOPES]; +} + +/** The space-separated scope string sent on the authorize request. */ +export function getGoogleHealthScopeString(): string { + return resolveGoogleHealthScopes().join(" "); +} + +export interface GoogleHealthTokenResponse { + access_token: string; + /** + * Present on the code exchange and whenever Google issues a new refresh + * token; absent on a routine refresh, because Google does not rotate. + */ + refresh_token?: string; + expires_in: number; + scope?: string; + token_type?: string; +} + +export const googleHealthOAuth = defineOAuthClient({ + service: "google-health", + authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth", + tokenUrl: "https://oauth2.googleapis.com/token", + redirectUri: () => + checkedRedirectUri( + "Google Health", + "GOOGLE_HEALTH_REDIRECT_URI", + "/api/google-health/callback", + ), + authorizeParams: () => ({ + scope: getGoogleHealthScopeString(), + access_type: "offline", + prompt: "consent", + }), + pkce: true, + clientAuth: { kind: "basic", idInForm: { exchange: true, refresh: true } }, + refresh: true, + classify: (httpStatus, body) => { + const verdict = classifyHttpStatus(httpStatus); + return { + classification: + oauthErrorCode(body) === "invalid_grant" + ? "reauth_required" + : verdict.classification, + status: verdict.httpStatus, + reason: verdict.reason, + }; + }, + error: (failure) => + new GoogleHealthApiError({ + verb: failure.verb, + classification: failure.classification, + httpStatus: failure.status, + reason: failure.reason, + upstreamError: failure.upstreamError, + }), +}); diff --git a/src/lib/google-health/request.ts b/src/lib/google-health/request.ts new file mode 100644 index 000000000..ce380261e --- /dev/null +++ b/src/lib/google-health/request.ts @@ -0,0 +1,390 @@ +/** + * The Google Health request layer: one data request with the error detail + * read out of a refusal (redacted), the transient retry, the rate-limit wait, + * and the civil-time filters the list endpoint takes. + */ +import { getEvent } from "@/lib/logging/context"; +import { SafeFetchError } from "@/lib/safe-fetch"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; +import { classifyGoogleHealthResponse } from "./response-classifier"; +import { type GoogleHealthDataType } from "./mappers"; +import { retryAfterMsOf } from "@/lib/integrations/oauth-kit/retry-after"; +import { stopRequested } from "./budget"; + +export const GOOGLE_HEALTH_API_BASE = "https://health.googleapis.com/v4"; + +const pad2 = (n: number): string => String(n).padStart(2, "0"); + +/** + * Format a UTC instant as the offset-less civil wall clock an observer in `tz` + * reads at that moment (`YYYY-MM-DDTHH:MM:SS`, no `Z`, no offset) — the bound + * format the session `civil_start_time` filter expects. Without `tz` the bound + * forms in UTC. + */ +export function formatCivilBound(instant: Date, tz?: string): string { + if (!tz) return instant.toISOString().slice(0, 19); + const p = wallClockInTz(instant, tz); + return `${p.year}-${pad2(p.month)}-${pad2(p.day)}T${pad2(p.hour)}:${pad2(p.minute)}:${pad2(p.second)}`; +} + +/** + * The daily-summary `.date` filter prefix style. The official docs contradict + * themselves on this one point: + * - The data-types index states "In a filter parameter … the data type name + * must be in snake case, such as `body_fat`" and its per-type "filter + * parameter" column lists `daily_heart_rate_variability` etc. + * - The `dataPoints.list` reference's ONLY worked daily-summary example is + * `dailyHeartRateVariability.date < "2024-08-15"` — the camelCase payload + * union key, NOT snake_case. + * Live behaviour: the snake_case daily filter is accepted (HTTP 200) but has + * returned zero rows on accounts whose companion app visibly holds daily HRV / + * RHR — consistent with a vacuously-unmatched predicate. The client therefore + * sends the worked-example camelCase form first and falls back to snake_case + * once if the very first page is rejected with a 400 (see `fetchDataPoints`). + */ +export type GoogleHealthDateFilterStyle = "camel" | "snake"; + +/** + * Build the incremental `filter` field + bound for one list-read data type. + * Centralised so the predicate and the read-time anchor resolution can never + * drift. The legal filter fields are per-shape (anything else 400s): + * - `sample` → `{filter}.sample_time.physical_time` (RFC-3339). + * - `date` → `{key|filter}.date` (`YYYY-MM-DD`) — prefix style per + * `GoogleHealthDateFilterStyle` (docs conflict; camel is the worked + * example, snake the fallback). + * - `sessionEnd` → sleep only: `{filter}.interval.end_time` (RFC-3339) — the + * ONLY filterable time field on sleep; watermark semantics improve too (a + * night is fetched when it ENDS after the cursor). + * - `civilStart` → exercise: `{filter}.interval.civil_start_time` with an + * offset-less civil bound in the user's zone. + * `rollup` types never build a list filter — they read via `:dailyRollUp`. + */ +export function incrementalFilter( + dataType: GoogleHealthDataType, + start: Date, + tz?: string, + dateStyle: GoogleHealthDateFilterStyle = "camel", +): { field: string; bound: string } { + switch (dataType.timeField) { + case "sample": + return { + field: `${dataType.filter}.sample_time.physical_time`, + bound: start.toISOString(), + }; + case "sessionEnd": + return { + field: `${dataType.filter}.interval.end_time`, + bound: start.toISOString(), + }; + case "civilStart": + return { + field: `${dataType.filter}.interval.civil_start_time`, + bound: formatCivilBound(start, tz), + }; + case "date": + return { + field: `${dateStyle === "camel" ? dataType.key : dataType.filter}.date`, + // eslint-disable-next-line healthlog/no-utc-day-key -- baseline: request window edge for the provider API, not a displayed day; rows carry their own dates + bound: start.toISOString().slice(0, 10), + }; + case "rollup": + throw new Error( + `Google Health data type ${dataType.path} reads via :dailyRollUp, not dataPoints.list`, + ); + } +} + +/** + * Redact + bound an upstream Google API error message before it reaches an + * error object / wide event: strip bearer tokens, drop query strings from any + * embedded URL (they can carry filters echoing user data or tokens), cap at + * 200 chars. + */ +function redactApiErrorMessage(msg: string): string { + return msg + .replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]") + .replace(/(https?:\/\/[^\s"'?]+)\?[^\s"']*/gi, "$1?[REDACTED]") + .slice(0, 200); +} + +/** + * Collect the `google.rpc.BadRequest` field violations out of an AIP-193 + * `error.details[]` array as `field: description` fragments. Only the field + * PATH and the description sentence survive — both run through the message + * redactor and a per-fragment cap, and at most three violations are kept, so + * no request value can ride an error string into the logs. + */ +function extractFieldViolations(details: unknown): string[] { + if (!Array.isArray(details)) return []; + const out: string[] = []; + for (const d of details) { + if (!d || typeof d !== "object") continue; + const fv = (d as { fieldViolations?: unknown }).fieldViolations; + if (!Array.isArray(fv)) continue; + for (const v of fv) { + if (out.length >= 3) return out; + if (!v || typeof v !== "object") continue; + const o = v as { field?: unknown; description?: unknown }; + const field = + typeof o.field === "string" + ? redactApiErrorMessage(o.field).slice(0, 80) + : undefined; + const description = + typeof o.description === "string" + ? redactApiErrorMessage(o.description).slice(0, 120) + : undefined; + if (!field && !description) continue; + out.push([field, description].filter(Boolean).join(": ")); + } + } + return out; +} + +/** + * Extract the AIP-193 error envelope (`{"error":{code,message,status,details}}`) + * from a non-2xx Google Health body into a short redacted + * `STATUS: message [field: description]` detail string, or undefined when the + * body carries no such envelope. This is what makes a field-grammar 400 + * (`INVALID_ARGUMENT: Invalid filter …`) diagnosable from operator logs: the + * `details[]` BadRequest field violations name the exact offending request + * field. (The OAuth token endpoint uses the flat `{"error":"invalid_grant"}` + * shape instead — handled in `postToken`.) + */ +export function extractGoogleApiErrorDetail(json: unknown): string | undefined { + if (!json || typeof json !== "object") return undefined; + const e = (json as { error?: unknown }).error; + if (!e || typeof e !== "object") return undefined; + const o = e as { status?: unknown; message?: unknown; details?: unknown }; + const status = typeof o.status === "string" ? o.status : undefined; + const message = + typeof o.message === "string" + ? redactApiErrorMessage(o.message) + : undefined; + const violations = extractFieldViolations(o.details); + if (!status && !message && violations.length === 0) return undefined; + const head = [status, message].filter(Boolean).join(": "); + const tail = violations.length > 0 ? `[${violations.join("; ")}]` : ""; + return [head, tail].filter(Boolean).join(" ").slice(0, 500); +} + +// ─── Token source + one data request ─────────────────────────── + +/** + * Where a walk gets its access token. A plain string is used as given. A + * function is asked before every request, so a walk that runs for longer than + * the token lives (an access token lasts an hour; a full-history heart-rate + * walk can take several) picks up the refreshed one instead of carrying an + * expired token into a 401. `forceRefresh` asks for a new token even though + * the current one has not reached its expiry, after Google refused it. + */ +export type GoogleHealthTokenSource = (opts: { + forceRefresh: boolean; +}) => Promise; + +export type GoogleHealthAccessToken = string | GoogleHealthTokenSource; + +/** + * How a data request sits out Google's per-user, per-minute quota (429 + * `RESOURCE_EXHAUSTED`). The quota refills within the minute, so a short wait + * turns a throttled request into a completed one, where failing it used to + * cost the whole collection. The waits follow `Retry-After` (or the + * `google.rpc.RetryInfo` delay in the body) when Google sends one, and + * otherwise double from `baseDelayMs`: 2, 4, 8, 16, 32 s, a minute and a + * bit in all, which spans a full quota window. A requested delay above + * `maxDelayMs` is not slept out; the request fails as the transient it is + * and the next run picks the window up. + */ +export const GOOGLE_HEALTH_RATE_LIMIT = { + maxRetries: 5, + baseDelayMs: 2_000, + maxDelayMs: 60_000, +} as const; + +/** + * The delay Google asked for on a 429, in ms: the `Retry-After` header in + * either of its forms, else a `google.rpc.RetryInfo` detail (`"30s"`) in the + * error body. Undefined when it asked for nothing readable. + */ +export function googleHealthRetryDelayMs( + res: Response, + json: unknown, + now: number = Date.now(), +): number | undefined { + const fromHeader = retryAfterMsOf(res, now); + if (fromHeader !== undefined) return fromHeader; + const details = (json as { error?: { details?: unknown } } | null)?.error + ?.details; + if (Array.isArray(details)) { + for (const d of details) { + const delay = (d as { retryDelay?: unknown } | null)?.retryDelay; + if (typeof delay === "string") { + const m = /^(\d+(?:\.\d+)?)s$/.exec(delay.trim()); + if (m) return Math.round(Number.parseFloat(m[1]!) * 1000); + } + } + } + return undefined; +} + +/** + * How a data request rides out a server that did not answer properly: a + * 500, 502, 503 or 504, a timeout, or a broken connection. A short history + * reads a handful of pages and rarely meets one; a full-history heart-rate + * walk reads thousands, and a single one used to end the walk and throw away + * everything it had read. Three tries more, 2, 4 and 8 s apart (or the + * `Retry-After` a 503 names, up to a minute), before the request fails. + */ +export const GOOGLE_HEALTH_TRANSIENT_RETRY = { + maxRetries: 3, + baseDelayMs: 2_000, +}; + +const GOOGLE_HEALTH_TRANSIENT_STATUSES = new Set([500, 502, 503, 504]); + +function waitMs(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +interface GoogleHealthDataResponse { + status: number; + json: T | null; + verdict: ReturnType; + apiErrorDetail: string | undefined; +} + +/** + * Send one data request and answer with the response it finally got. + * + * Three answers are handled here rather than handed back: + * - 429: the request waits (see `GOOGLE_HEALTH_RATE_LIMIT`) and is sent + * again, up to `maxRetries` times, unless the caller's stop condition + * says to end the walk. + * - a server error or no answer at all (see + * `GOOGLE_HEALTH_TRANSIENT_RETRY`): the same, with its own count. + * - 401 with a token source: the token expired or was revoked between + * the source's last check and this request. The source is asked once for + * a fresh token and the request is repeated. A second 401, or a 401 on a + * fixed string token, is returned like any other answer. + * + * Every attempt is recorded as its own external call, so the wide event + * shows the throttling and the waits rather than only their outcome. + */ +export async function googleHealthDataRequest( + token: GoogleHealthAccessToken, + label: string, + send: (accessToken: string) => Promise, + expectedFallbackNote: ( + status: number, + apiErrorDetail: string | undefined, + ) => string | undefined = () => undefined, +): Promise> { + let rateLimited = 0; + let transient = 0; + let refreshed = false; + let forceRefresh = false; + for (;;) { + const accessToken = + typeof token === "string" ? token : await token({ forceRefresh }); + forceRefresh = false; + const started = performance.now(); + let res: Response; + try { + res = await send(accessToken); + } catch (err) { + // No answer at all: the request timed out or the connection broke. + // On a walk of a thousand pages one of those is a matter of time, and + // it cost the whole walk. + const retry = + err instanceof SafeFetchError && + (err.kind === "timeout" || err.kind === "network") && + transient < GOOGLE_HEALTH_TRANSIENT_RETRY.maxRetries && + !stopRequested(); + const delayMs = + GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs * 2 ** transient; + getEvent()?.addExternalCall({ + service: "google-health", + method: label, + duration_ms: Math.round(performance.now() - started), + ...(retry + ? { note: `no_response_retrying_in_ms=${delayMs}` } + : { error: err instanceof Error ? err.message : String(err) }), + }); + if (!retry) throw err; + transient += 1; + await waitMs(delayMs); + continue; + } + const json = (await res.json().catch(() => null)) as T | null; + const verdict = classifyGoogleHealthResponse(res.status); + const apiErrorDetail = + verdict.classification === "success" + ? undefined + : extractGoogleApiErrorDetail(json); + + const retryAfterMs = + res.status === 429 ? googleHealthRetryDelayMs(res, json) : undefined; + const backoffMs = + res.status === 429 + ? (retryAfterMs ?? + GOOGLE_HEALTH_RATE_LIMIT.baseDelayMs * 2 ** rateLimited) + : 0; + const willWait = + res.status === 429 && + rateLimited < GOOGLE_HEALTH_RATE_LIMIT.maxRetries && + backoffMs <= GOOGLE_HEALTH_RATE_LIMIT.maxDelayMs && + !stopRequested(); + const willRefresh = + res.status === 401 && typeof token !== "string" && !refreshed; + const transientDelayMs = GOOGLE_HEALTH_TRANSIENT_STATUSES.has(res.status) + ? Math.min( + googleHealthRetryDelayMs(res, json) ?? + GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs * 2 ** transient, + GOOGLE_HEALTH_RATE_LIMIT.maxDelayMs, + ) + : 0; + const willRetryTransient = + GOOGLE_HEALTH_TRANSIENT_STATUSES.has(res.status) && + transient < GOOGLE_HEALTH_TRANSIENT_RETRY.maxRetries && + !stopRequested(); + + getEvent()?.addExternalCall({ + service: "google-health", + method: label, + duration_ms: Math.round(performance.now() - started), + status: res.status, + ...(expectedFallbackNote(res.status, apiErrorDetail) + ? { note: expectedFallbackNote(res.status, apiErrorDetail) } + : willWait + ? { note: `rate_limited_retrying_in_ms=${backoffMs}` } + : willRefresh + ? { note: "token_rejected_retrying_with_fresh_token" } + : willRetryTransient + ? { note: `server_error_retrying_in_ms=${transientDelayMs}` } + : { + error: + verdict.classification === "success" + ? undefined + : apiErrorDetail + ? `${verdict.reason} ${apiErrorDetail}` + : verdict.reason, + }), + }); + + if (willWait) { + rateLimited += 1; + await waitMs(backoffMs); + continue; + } + if (willRetryTransient) { + transient += 1; + await waitMs(transientDelayMs); + continue; + } + if (willRefresh) { + refreshed = true; + forceRefresh = true; + continue; + } + return { status: res.status, json, verdict, apiErrorDetail }; + } +} diff --git a/src/lib/google-health/sync-activity.ts b/src/lib/google-health/sync-activity.ts index 0c17e2209..075256b44 100644 --- a/src/lib/google-health/sync-activity.ts +++ b/src/lib/google-health/sync-activity.ts @@ -24,18 +24,18 @@ * A per-data-class 403 soft-skips THAT class (returns 0, leaves the connection * connected) — the Restricted bundles are granted independently. */ +import { fetchDailyRollUp } from "./client"; +import { mapVo2Max } from "./mappers"; +import { fetchDataPoints } from "./data-points"; import { GOOGLE_HEALTH_DATA_TYPES, type GoogleHealthDataType, type GoogleHealthMappedMeasurement, - fetchDailyRollUp, - fetchDataPoints, mapActiveEnergy, mapDistance, mapFloors, mapSteps, - mapVo2Max, -} from "./client"; +} from "./mappers"; import { getValidToken, googleHealthTokenSource, diff --git a/src/lib/google-health/sync-core.ts b/src/lib/google-health/sync-core.ts index 282a3a112..44f200e28 100644 --- a/src/lib/google-health/sync-core.ts +++ b/src/lib/google-health/sync-core.ts @@ -38,11 +38,10 @@ import { import { dropImplausibleMeasurements } from "@/lib/measurements/plausibility-gate"; import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit"; import { recordSyncFailure, type FailureKind } from "@/lib/integrations/status"; -import { - collapseToTypeDayKeys, - recomputeBucketsForKeys, -} from "@/lib/rollups/measurement-rollups"; -import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; +import { collapseToTypeDayKeys } from "@/lib/rollups/measurement-rollups"; +import { afterProviderWrite } from "@/lib/integrations/sync-kit/after-write"; +import { type GoogleHealthResumePoint } from "./data-points"; +import { type GoogleHealthTokenSource } from "./request"; import { googleHealthOutcomeTruncated, googleHealthSliceExhausted, @@ -51,9 +50,7 @@ import { noteGoogleHealthMapped, noteGoogleHealthOutcomeFailure, noteGoogleHealthWritten, - type GoogleHealthResumePoint, - type GoogleHealthTokenSource, -} from "./client"; +} from "./budget"; import { GOOGLE_HEALTH_TOKEN_REFRESH_BUFFER_MS, googleHealthTokenDescriptor, @@ -1553,24 +1550,14 @@ export async function upsertGoogleHealthMeasurements( return { imported, touched, inserted: insertedRows }; } - try { - const keys = collapseToTypeDayKeys(touched); - await recomputeBucketsForKeys(userId, keys); - invalidateStatusInsightsForTypes( - userId, - keys.map((k) => k.type), - { source: "sync" }, - ).catch((err) => { - getEvent()?.addWarning( - `google-health: status-insight invalidate failed for ${userId}: ${err}`, - ); - }); - } catch (err) { - getEvent()?.addWarning( - `google-health: rollup recompute failed for ${userId}: ${err}`, - ); - noteHardFailure("rollup"); - } + // The arrivals went out per chunk above; the shared tail folds, marks the + // caches and re-warms the status cards. A failed fold fails the verdict. + const { rollupFailed } = await afterProviderWrite({ + userId, + provider: "google-health", + touched, + }); + if (rollupFailed) noteHardFailure("rollup"); noteGoogleHealthWritten(imported); return { imported, touched, inserted: insertedRows }; diff --git a/src/lib/google-health/sync-metrics.ts b/src/lib/google-health/sync-metrics.ts index 8115ac280..bac3fbcb7 100644 --- a/src/lib/google-health/sync-metrics.ts +++ b/src/lib/google-health/sync-metrics.ts @@ -26,15 +26,17 @@ * can be granted independently in the Google consent flow. */ import { - GOOGLE_HEALTH_DATA_TYPES, GOOGLE_HEALTH_DEFAULT_MAX_PAGES, GOOGLE_HEALTH_DENSE_MAX_PAGES, + fetchDataPoints, + forEachDataPointPage, +} from "./data-points"; +import { type GoogleHealthTokenSource } from "./request"; +import { + GOOGLE_HEALTH_DATA_TYPES, type GoogleHealthDataPoint, type GoogleHealthDataType, type GoogleHealthMappedMeasurement, - type GoogleHealthTokenSource, - fetchDataPoints, - forEachDataPointPage, googleHealthSampleInstant, mapBloodGlucose, mapBodyFat, @@ -47,7 +49,7 @@ import { mapRestingHeartRate, mapWeight, mapWristTemperature, -} from "./client"; +} from "./mappers"; import { googleHealthBackfillHorizon } from "./backfill-bounds"; import { getValidToken, diff --git a/src/lib/google-health/sync-sleep.ts b/src/lib/google-health/sync-sleep.ts index b29d40ac1..32ff8dd8e 100644 --- a/src/lib/google-health/sync-sleep.ts +++ b/src/lib/google-health/sync-sleep.ts @@ -22,10 +22,9 @@ */ import { GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE, - GOOGLE_HEALTH_DATA_TYPES, fetchDataPoints, - mapSleepSessionDetailed, -} from "./client"; +} from "./data-points"; +import { GOOGLE_HEALTH_DATA_TYPES, mapSleepSessionDetailed } from "./mappers"; import { getValidToken, googleHealthTokenSource, diff --git a/src/lib/google-health/sync-workout.ts b/src/lib/google-health/sync-workout.ts index 24671cf79..22eaabf26 100644 --- a/src/lib/google-health/sync-workout.ts +++ b/src/lib/google-health/sync-workout.ts @@ -18,11 +18,13 @@ */ import { GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE, - GOOGLE_HEALTH_DATA_TYPES, fetchDataPoints, +} from "./data-points"; +import { + GOOGLE_HEALTH_DATA_TYPES, mapWorkout, type GoogleHealthMappedWorkout, -} from "./client"; +} from "./mappers"; import { getValidToken, googleHealthTokenSource, diff --git a/src/lib/google-health/sync.ts b/src/lib/google-health/sync.ts index 0caff675a..02676d189 100644 --- a/src/lib/google-health/sync.ts +++ b/src/lib/google-health/sync.ts @@ -2,18 +2,14 @@ import pLimit from "p-limit"; import { prisma } from "@/lib/db"; import { annotate, getEvent } from "@/lib/logging/context"; import { recordSyncSuccess } from "@/lib/integrations/status"; -import { - collapseToTypeDayKeys, - recomputeUserRollups, -} from "@/lib/rollups/measurement-rollups"; -import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; +import { afterProviderBackfill } from "@/lib/integrations/sync-kit/after-write"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { resolveUserTimezone } from "@/lib/tz/resolver"; import { runWithGoogleHealthClientOutcome, runWithGoogleHealthStop, type GoogleHealthClientOutcome, -} from "./client"; +} from "./budget"; import { syncUserActivity } from "./sync-activity"; import { syncUserMetrics } from "./sync-metrics"; import { syncUserSleep } from "./sync-sleep"; @@ -362,20 +358,10 @@ async function runGoogleHealthSyncCycle( if (opts.fullSync && cycle.deferredRollupKeys.length > 0) { try { - const days = collapseToTypeDayKeys(cycle.deferredRollupKeys); - const types = Array.from(new Set(days.map((key) => key.type))); - const sorted = days - .map((key) => key.measuredAt.getTime()) - .sort((a, b) => a - b); - const from = new Date(sorted[0]!); - const to = new Date(sorted[sorted.length - 1]! + 24 * 60 * 60 * 1000); - await recomputeUserRollups(userId, { types, from, to }); - invalidateStatusInsightsForTypes(userId, types, { - source: "sync", - }).catch((err) => { - getEvent()?.addWarning( - `google-health: status-insight invalidate failed for ${userId}: ${err}`, - ); + await afterProviderBackfill({ + userId, + provider: "google-health", + keys: cycle.deferredRollupKeys, }); } catch (err) { anyFailed = true; diff --git a/src/lib/insights/derived/__tests__/sleep-score-oura-rhythm.test.ts b/src/lib/insights/derived/__tests__/sleep-score-oura-rhythm.test.ts index 302bd93d3..aef0db6c5 100644 --- a/src/lib/insights/derived/__tests__/sleep-score-oura-rhythm.test.ts +++ b/src/lib/insights/derived/__tests__/sleep-score-oura-rhythm.test.ts @@ -20,7 +20,8 @@ vi.mock("@/lib/tz/resolver", () => ({ })); import { prisma } from "@/lib/db"; -import { mapSleep, type OuraSleep } from "@/lib/oura/client"; +import { mapSleep } from "@/lib/oura/map"; +import { type OuraSleep } from "@/lib/oura/wire-types"; import { computeSleepScore, reconstructNights, diff --git a/src/lib/integrations/__tests__/redirect-uri-empty-env.test.ts b/src/lib/integrations/__tests__/redirect-uri-empty-env.test.ts index 25924210c..1b5ae515e 100644 --- a/src/lib/integrations/__tests__/redirect-uri-empty-env.test.ts +++ b/src/lib/integrations/__tests__/redirect-uri-empty-env.test.ts @@ -7,29 +7,29 @@ */ import { afterEach, describe, expect, it, vi } from "vitest"; -import { getWithingsRedirectUri } from "@/lib/withings/client"; -import { getFitbitRedirectUri } from "@/lib/fitbit/client"; -import { getGoogleHealthRedirectUri } from "@/lib/google-health/client"; -import { getPolarRedirectUri } from "@/lib/polar/client"; -import { getWhoopRedirectUri } from "@/lib/whoop/client"; -import { getStravaRedirectUri } from "@/lib/strava/client"; -import { getOuraRedirectUri } from "@/lib/oura/client"; +import { withingsOAuth } from "@/lib/withings/oauth"; +import { fitbitOAuth } from "@/lib/fitbit/oauth"; +import { googleHealthOAuth } from "@/lib/google-health/oauth"; +import { polarOAuth } from "@/lib/polar/oauth"; +import { whoopOAuth } from "@/lib/whoop/oauth"; +import { stravaOAuth } from "@/lib/strava/oauth"; +import { ouraOAuth } from "@/lib/oura/oauth"; const APP = "https://health.example.test"; const CASES: Array<[string, string, () => string, string]> = [ - ["Withings", "WITHINGS_REDIRECT_URI", getWithingsRedirectUri, "withings"], - ["Fitbit", "FITBIT_REDIRECT_URI", getFitbitRedirectUri, "fitbit"], + ["Withings", "WITHINGS_REDIRECT_URI", withingsOAuth.redirectUri, "withings"], + ["Fitbit", "FITBIT_REDIRECT_URI", fitbitOAuth.redirectUri, "fitbit"], [ "Google Health", "GOOGLE_HEALTH_REDIRECT_URI", - getGoogleHealthRedirectUri, + googleHealthOAuth.redirectUri, "google-health", ], - ["Polar", "POLAR_REDIRECT_URI", getPolarRedirectUri, "polar"], - ["WHOOP", "WHOOP_REDIRECT_URI", getWhoopRedirectUri, "whoop"], - ["Strava", "STRAVA_REDIRECT_URI", getStravaRedirectUri, "strava"], - ["Oura", "OURA_REDIRECT_URI", getOuraRedirectUri, "oura"], + ["Polar", "POLAR_REDIRECT_URI", polarOAuth.redirectUri, "polar"], + ["WHOOP", "WHOOP_REDIRECT_URI", whoopOAuth.redirectUri, "whoop"], + ["Strava", "STRAVA_REDIRECT_URI", stravaOAuth.redirectUri, "strava"], + ["Oura", "OURA_REDIRECT_URI", ouraOAuth.redirectUri, "oura"], ]; afterEach(() => { @@ -56,8 +56,12 @@ describe.each(CASES)("%s redirect URI", (_name, variable, resolve, path) => { describe("with neither variable set", () => { it.each([ - ["Fitbit", "FITBIT_REDIRECT_URI", getFitbitRedirectUri], - ["Google Health", "GOOGLE_HEALTH_REDIRECT_URI", getGoogleHealthRedirectUri], + ["Fitbit", "FITBIT_REDIRECT_URI", fitbitOAuth.redirectUri], + [ + "Google Health", + "GOOGLE_HEALTH_REDIRECT_URI", + googleHealthOAuth.redirectUri, + ], ] as const)("%s still says it is not configured", (_n, variable, resolve) => { vi.stubEnv(variable, ""); vi.stubEnv("NEXT_PUBLIC_APP_URL", " "); diff --git a/src/lib/integrations/callback-urls.ts b/src/lib/integrations/callback-urls.ts index 5f6b00d39..7f9eb384f 100644 --- a/src/lib/integrations/callback-urls.ts +++ b/src/lib/integrations/callback-urls.ts @@ -20,13 +20,13 @@ * path for the guide and skip the origin comparison. */ -import { getFitbitRedirectUri } from "@/lib/fitbit/client"; -import { getGoogleHealthRedirectUri } from "@/lib/google-health/client"; -import { getOuraRedirectUri } from "@/lib/oura/client"; -import { getPolarRedirectUri } from "@/lib/polar/client"; -import { getStravaRedirectUri } from "@/lib/strava/client"; -import { getWhoopRedirectUri } from "@/lib/whoop/client"; -import { getWithingsRedirectUri } from "@/lib/withings/client"; +import { fitbitOAuth } from "@/lib/fitbit/oauth"; +import { googleHealthOAuth } from "@/lib/google-health/oauth"; +import { ouraOAuth } from "@/lib/oura/oauth"; +import { polarOAuth } from "@/lib/polar/oauth"; +import { stravaOAuth } from "@/lib/strava/oauth"; +import { whoopOAuth } from "@/lib/whoop/oauth"; +import { withingsOAuth } from "@/lib/withings/oauth"; export const INTEGRATION_CALLBACK_PROVIDERS = [ "withings", @@ -48,13 +48,13 @@ export type IntegrationCallbackUrls = Record< >; const RESOLVERS: Record string> = { - withings: getWithingsRedirectUri, - whoop: getWhoopRedirectUri, - fitbit: getFitbitRedirectUri, - "google-health": getGoogleHealthRedirectUri, - polar: getPolarRedirectUri, - oura: getOuraRedirectUri, - strava: getStravaRedirectUri, + withings: withingsOAuth.redirectUri, + whoop: whoopOAuth.redirectUri, + fitbit: fitbitOAuth.redirectUri, + "google-health": googleHealthOAuth.redirectUri, + polar: polarOAuth.redirectUri, + oura: ouraOAuth.redirectUri, + strava: stravaOAuth.redirectUri, }; function resolve(provider: IntegrationCallbackProvider): string | null { diff --git a/src/lib/integrations/connection-health-load.ts b/src/lib/integrations/connection-health-load.ts index e0134cb09..6b32e9d9a 100644 --- a/src/lib/integrations/connection-health-load.ts +++ b/src/lib/integrations/connection-health-load.ts @@ -41,7 +41,7 @@ import { } from "./metric-freshness"; import { parseWithingsApiPlanState } from "@/lib/withings/api-plan"; import { withingsPlanUnavailableFeatures } from "@/lib/withings/plan-features"; -import { hasActivityScope } from "@/lib/withings/client"; +import { hasActivityScope } from "@/lib/withings/oauth"; import { readOuraScopeDenied } from "@/lib/oura/credentials"; import { ouraScopeReconnectList } from "@/lib/oura/scope"; import { localDayWindow } from "@/lib/tz/local-day"; diff --git a/src/lib/integrations/oauth-kit/__tests__/__golden__/oauth-wire.json b/src/lib/integrations/oauth-kit/__tests__/__golden__/oauth-wire.json new file mode 100644 index 000000000..0b3d3b29d --- /dev/null +++ b/src/lib/integrations/oauth-kit/__tests__/__golden__/oauth-wire.json @@ -0,0 +1,4734 @@ +{ + "withings": { + "redirectUri": "https://healthlog.example/api/withings/callback", + "authorize": { + "url": "https://account.withings.com/oauth2_user/authorize2", + "query": { + "client_id": "client-id-1", + "redirect_uri": "https://healthlog.example/api/withings/callback", + "response_type": "code", + "scope": "user.metrics,user.activity", + "state": "state-nonce-1" + } + }, + "exchange": { + "ok": { + "result": { + "userid": "4711", + "access_token": "access-1", + "refresh_token": "refresh-1", + "expires_in": 3600, + "token_type": "Bearer", + "scope": "granted" + }, + "requests": [ + { + "method": "POST", + "url": "https://wbsapi.withings.net/v2/oauth2", + "query": {}, + "headers": { + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "action": "requesttoken", + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "code": "code-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/withings/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "withings", + "method": "exchangeCode", + "duration_ms": 0, + "status": 200 + } + ] + } + ], + "unansweredScript": 0 + }, + "empty_body": { + "error": { + "name": "WithingsApiError", + "message": "Withings token error: ?", + "classification": "transient", + "reason": "no_status_field", + "verb": "token" + }, + "requests": [ + { + "method": "POST", + "url": "https://wbsapi.withings.net/v2/oauth2", + "query": {}, + "headers": { + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "action": "requesttoken", + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "code": "code-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/withings/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "withings", + "method": "exchangeCode", + "duration_ms": 0, + "status": 200, + "error": "no_status_field" + } + ] + } + ], + "unansweredScript": 0 + }, + "invalid_grant": { + "error": { + "name": "WithingsApiError", + "message": "Withings token error: 503 - Invalid Params", + "classification": "transient", + "withingsStatus": 503, + "reason": "withings_503", + "verb": "token" + }, + "requests": [ + { + "method": "POST", + "url": "https://wbsapi.withings.net/v2/oauth2", + "query": {}, + "headers": { + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "action": "requesttoken", + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "code": "code-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/withings/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "withings", + "method": "exchangeCode", + "duration_ms": 0, + "status": 200, + "error": "withings_503" + } + ] + } + ], + "unansweredScript": 0 + }, + "revoked": { + "error": { + "name": "WithingsApiError", + "message": "Withings token error: 100 - The hash is missing", + "classification": "reauth_required", + "withingsStatus": 100, + "reason": "withings_100", + "verb": "token" + }, + "requests": [ + { + "method": "POST", + "url": "https://wbsapi.withings.net/v2/oauth2", + "query": {}, + "headers": { + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "action": "requesttoken", + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "code": "code-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/withings/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "withings", + "method": "exchangeCode", + "duration_ms": 0, + "status": 200, + "error": "withings_100" + } + ] + } + ], + "unansweredScript": 0 + }, + "invalid_client": { + "error": { + "name": "WithingsApiError", + "message": "Withings token error: 401 - invalid_client", + "classification": "transient", + "withingsStatus": 401, + "reason": "withings_401_unknown", + "verb": "token" + }, + "requests": [ + { + "method": "POST", + "url": "https://wbsapi.withings.net/v2/oauth2", + "query": {}, + "headers": { + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "action": "requesttoken", + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "code": "code-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/withings/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "withings", + "method": "exchangeCode", + "duration_ms": 0, + "status": 200, + "error": "withings_401_unknown" + } + ] + } + ], + "unansweredScript": 0 + }, + "rate_limited": { + "error": { + "name": "WithingsApiError", + "message": "Withings token error: 601 - Too Many Requests", + "classification": "transient", + "withingsStatus": 601, + "reason": "withings_601", + "verb": "token" + }, + "requests": [ + { + "method": "POST", + "url": "https://wbsapi.withings.net/v2/oauth2", + "query": {}, + "headers": { + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "action": "requesttoken", + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "code": "code-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/withings/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "withings", + "method": "exchangeCode", + "duration_ms": 0, + "status": 200, + "error": "withings_601" + } + ] + } + ], + "unansweredScript": 0 + }, + "outage": { + "error": { + "name": "WithingsApiError", + "message": "Withings token error: ?", + "classification": "transient", + "reason": "http_503", + "verb": "token" + }, + "requests": [ + { + "method": "POST", + "url": "https://wbsapi.withings.net/v2/oauth2", + "query": {}, + "headers": { + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "action": "requesttoken", + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "code": "code-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/withings/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "withings", + "method": "exchangeCode", + "duration_ms": 0, + "status": 503, + "error": "http_503" + } + ] + } + ], + "unansweredScript": 0 + } + }, + "refresh": { + "ok": { + "result": { + "userid": "4711", + "access_token": "access-1", + "refresh_token": "refresh-1", + "expires_in": 3600, + "token_type": "Bearer", + "scope": "granted" + }, + "requests": [ + { + "method": "POST", + "url": "https://wbsapi.withings.net/v2/oauth2", + "query": {}, + "headers": { + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "action": "requesttoken", + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "withings", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 200 + } + ] + } + ], + "unansweredScript": 0 + }, + "empty_body": { + "error": { + "name": "WithingsApiError", + "message": "Withings refresh error: ?", + "classification": "transient", + "reason": "no_status_field", + "verb": "refresh" + }, + "requests": [ + { + "method": "POST", + "url": "https://wbsapi.withings.net/v2/oauth2", + "query": {}, + "headers": { + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "action": "requesttoken", + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "withings", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 200, + "error": "no_status_field" + } + ] + } + ], + "unansweredScript": 0 + }, + "invalid_grant": { + "error": { + "name": "WithingsApiError", + "message": "Withings refresh error: 503 - Invalid Params", + "classification": "transient", + "withingsStatus": 503, + "reason": "withings_503", + "verb": "refresh" + }, + "requests": [ + { + "method": "POST", + "url": "https://wbsapi.withings.net/v2/oauth2", + "query": {}, + "headers": { + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "action": "requesttoken", + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "withings", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 200, + "error": "withings_503" + } + ] + } + ], + "unansweredScript": 0 + }, + "revoked": { + "error": { + "name": "WithingsApiError", + "message": "Withings refresh error: 100 - The hash is missing", + "classification": "reauth_required", + "withingsStatus": 100, + "reason": "withings_100", + "verb": "refresh" + }, + "requests": [ + { + "method": "POST", + "url": "https://wbsapi.withings.net/v2/oauth2", + "query": {}, + "headers": { + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "action": "requesttoken", + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "withings", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 200, + "error": "withings_100" + } + ] + } + ], + "unansweredScript": 0 + }, + "invalid_client": { + "error": { + "name": "WithingsApiError", + "message": "Withings refresh error: 401 - invalid_client", + "classification": "transient", + "withingsStatus": 401, + "reason": "withings_401_unknown", + "verb": "refresh" + }, + "requests": [ + { + "method": "POST", + "url": "https://wbsapi.withings.net/v2/oauth2", + "query": {}, + "headers": { + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "action": "requesttoken", + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "withings", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 200, + "error": "withings_401_unknown" + } + ] + } + ], + "unansweredScript": 0 + }, + "rate_limited": { + "error": { + "name": "WithingsApiError", + "message": "Withings refresh error: 601 - Too Many Requests", + "classification": "transient", + "withingsStatus": 601, + "reason": "withings_601", + "verb": "refresh" + }, + "requests": [ + { + "method": "POST", + "url": "https://wbsapi.withings.net/v2/oauth2", + "query": {}, + "headers": { + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "action": "requesttoken", + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "withings", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 200, + "error": "withings_601" + } + ] + } + ], + "unansweredScript": 0 + }, + "outage": { + "error": { + "name": "WithingsApiError", + "message": "Withings refresh error: ?", + "classification": "transient", + "reason": "http_503", + "verb": "refresh" + }, + "requests": [ + { + "method": "POST", + "url": "https://wbsapi.withings.net/v2/oauth2", + "query": {}, + "headers": { + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "action": "requesttoken", + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "withings", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 503, + "error": "http_503" + } + ] + } + ], + "unansweredScript": 0 + } + } + }, + "whoop": { + "redirectUri": "https://healthlog.example/api/whoop/callback", + "authorize": { + "url": "https://api.prod.whoop.com/oauth/oauth2/auth", + "query": { + "client_id": "client-id-1", + "redirect_uri": "https://healthlog.example/api/whoop/callback", + "response_type": "code", + "scope": "offline read:recovery read:sleep read:workout read:cycles read:profile read:body_measurement", + "state": "state-nonce-1" + } + }, + "exchange": { + "ok": { + "result": { + "access_token": "access-1", + "refresh_token": "refresh-1", + "expires_in": 3600, + "token_type": "Bearer", + "scope": "granted" + }, + "requests": [ + { + "method": "POST", + "url": "https://api.prod.whoop.com/oauth/oauth2/token", + "query": {}, + "headers": { + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "code": "code-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/whoop/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "whoop", + "method": "exchangeCode", + "duration_ms": 0, + "status": 200 + } + ] + } + ], + "unansweredScript": 0 + }, + "empty_body": { + "error": { + "name": "WhoopApiError", + "message": "WHOOP exchangeCode error: 200", + "classification": "transient", + "httpStatus": 200, + "reason": "empty_token_body", + "verb": "exchangeCode", + "vendor": "WHOOP" + }, + "requests": [ + { + "method": "POST", + "url": "https://api.prod.whoop.com/oauth/oauth2/token", + "query": {}, + "headers": { + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "code": "code-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/whoop/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "whoop", + "method": "exchangeCode", + "duration_ms": 0, + "status": 200 + } + ] + } + ], + "unansweredScript": 0 + }, + "invalid_grant": { + "error": { + "name": "WhoopApiError", + "message": "WHOOP exchangeCode error: 400 - invalid_grant", + "classification": "persistent", + "httpStatus": 400, + "reason": "http_400", + "verb": "exchangeCode", + "upstreamError": "invalid_grant", + "vendor": "WHOOP" + }, + "requests": [ + { + "method": "POST", + "url": "https://api.prod.whoop.com/oauth/oauth2/token", + "query": {}, + "headers": { + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "code": "code-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/whoop/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "whoop", + "method": "exchangeCode", + "duration_ms": 0, + "status": 400, + "error": "http_400" + } + ] + } + ], + "unansweredScript": 0 + }, + "strava_fault": { + "error": { + "name": "WhoopApiError", + "message": "WHOOP exchangeCode error: 400", + "classification": "persistent", + "httpStatus": 400, + "reason": "http_400", + "verb": "exchangeCode", + "vendor": "WHOOP" + }, + "requests": [ + { + "method": "POST", + "url": "https://api.prod.whoop.com/oauth/oauth2/token", + "query": {}, + "headers": { + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "code": "code-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/whoop/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "whoop", + "method": "exchangeCode", + "duration_ms": 0, + "status": 400, + "error": "http_400" + } + ] + } + ], + "unansweredScript": 0 + }, + "invalid_client": { + "error": { + "name": "WhoopApiError", + "message": "WHOOP exchangeCode error: 401 - invalid_client", + "classification": "reauth_required", + "httpStatus": 401, + "reason": "http_401", + "verb": "exchangeCode", + "upstreamError": "invalid_client", + "vendor": "WHOOP" + }, + "requests": [ + { + "method": "POST", + "url": "https://api.prod.whoop.com/oauth/oauth2/token", + "query": {}, + "headers": { + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "code": "code-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/whoop/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "whoop", + "method": "exchangeCode", + "duration_ms": 0, + "status": 401, + "error": "http_401" + } + ] + } + ], + "unansweredScript": 0 + }, + "rate_limited": { + "error": { + "name": "WhoopApiError", + "message": "WHOOP exchangeCode error: 429 - rate_limited", + "classification": "transient", + "httpStatus": 429, + "reason": "http_429", + "verb": "exchangeCode", + "upstreamError": "rate_limited", + "vendor": "WHOOP" + }, + "requests": [ + { + "method": "POST", + "url": "https://api.prod.whoop.com/oauth/oauth2/token", + "query": {}, + "headers": { + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "code": "code-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/whoop/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "whoop", + "method": "exchangeCode", + "duration_ms": 0, + "status": 429, + "error": "http_429" + } + ] + } + ], + "unansweredScript": 0 + }, + "outage": { + "error": { + "name": "WhoopApiError", + "message": "WHOOP exchangeCode error: 503", + "classification": "transient", + "httpStatus": 503, + "reason": "http_503", + "verb": "exchangeCode", + "vendor": "WHOOP" + }, + "requests": [ + { + "method": "POST", + "url": "https://api.prod.whoop.com/oauth/oauth2/token", + "query": {}, + "headers": { + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "code": "code-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/whoop/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "whoop", + "method": "exchangeCode", + "duration_ms": 0, + "status": 503, + "error": "http_503" + } + ] + } + ], + "unansweredScript": 0 + } + }, + "refresh": { + "ok": { + "result": { + "access_token": "access-1", + "refresh_token": "refresh-1", + "expires_in": 3600, + "token_type": "Bearer", + "scope": "granted" + }, + "requests": [ + { + "method": "POST", + "url": "https://api.prod.whoop.com/oauth/oauth2/token", + "query": {}, + "headers": { + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0", + "scope": "offline" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "whoop", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 200 + } + ] + } + ], + "unansweredScript": 0 + }, + "empty_body": { + "error": { + "name": "WhoopApiError", + "message": "WHOOP refreshAccessToken error: 200", + "classification": "transient", + "httpStatus": 200, + "reason": "empty_token_body", + "verb": "refreshAccessToken", + "vendor": "WHOOP" + }, + "requests": [ + { + "method": "POST", + "url": "https://api.prod.whoop.com/oauth/oauth2/token", + "query": {}, + "headers": { + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0", + "scope": "offline" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "whoop", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 200 + } + ] + } + ], + "unansweredScript": 0 + }, + "invalid_grant": { + "error": { + "name": "WhoopApiError", + "message": "WHOOP refreshAccessToken error: 400 - invalid_grant", + "classification": "persistent", + "httpStatus": 400, + "reason": "http_400", + "verb": "refreshAccessToken", + "upstreamError": "invalid_grant", + "vendor": "WHOOP" + }, + "requests": [ + { + "method": "POST", + "url": "https://api.prod.whoop.com/oauth/oauth2/token", + "query": {}, + "headers": { + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0", + "scope": "offline" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "whoop", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 400, + "error": "http_400" + } + ] + } + ], + "unansweredScript": 0 + }, + "strava_fault": { + "error": { + "name": "WhoopApiError", + "message": "WHOOP refreshAccessToken error: 400", + "classification": "persistent", + "httpStatus": 400, + "reason": "http_400", + "verb": "refreshAccessToken", + "vendor": "WHOOP" + }, + "requests": [ + { + "method": "POST", + "url": "https://api.prod.whoop.com/oauth/oauth2/token", + "query": {}, + "headers": { + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0", + "scope": "offline" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "whoop", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 400, + "error": "http_400" + } + ] + } + ], + "unansweredScript": 0 + }, + "invalid_client": { + "error": { + "name": "WhoopApiError", + "message": "WHOOP refreshAccessToken error: 401 - invalid_client", + "classification": "reauth_required", + "httpStatus": 401, + "reason": "http_401", + "verb": "refreshAccessToken", + "upstreamError": "invalid_client", + "vendor": "WHOOP" + }, + "requests": [ + { + "method": "POST", + "url": "https://api.prod.whoop.com/oauth/oauth2/token", + "query": {}, + "headers": { + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0", + "scope": "offline" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "whoop", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 401, + "error": "http_401" + } + ] + } + ], + "unansweredScript": 0 + }, + "rate_limited": { + "error": { + "name": "WhoopApiError", + "message": "WHOOP refreshAccessToken error: 429 - rate_limited", + "classification": "transient", + "httpStatus": 429, + "reason": "http_429", + "verb": "refreshAccessToken", + "upstreamError": "rate_limited", + "vendor": "WHOOP" + }, + "requests": [ + { + "method": "POST", + "url": "https://api.prod.whoop.com/oauth/oauth2/token", + "query": {}, + "headers": { + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0", + "scope": "offline" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "whoop", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 429, + "error": "http_429" + } + ] + } + ], + "unansweredScript": 0 + }, + "outage": { + "error": { + "name": "WhoopApiError", + "message": "WHOOP refreshAccessToken error: 503", + "classification": "transient", + "httpStatus": 503, + "reason": "http_503", + "verb": "refreshAccessToken", + "vendor": "WHOOP" + }, + "requests": [ + { + "method": "POST", + "url": "https://api.prod.whoop.com/oauth/oauth2/token", + "query": {}, + "headers": { + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0", + "scope": "offline" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "whoop", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 503, + "error": "http_503" + } + ] + } + ], + "unansweredScript": 0 + } + } + }, + "strava": { + "redirectUri": "https://healthlog.example/api/strava/callback", + "authorize": { + "url": "https://www.strava.com/oauth/authorize", + "query": { + "approval_prompt": "auto", + "client_id": "client-id-1", + "redirect_uri": "https://healthlog.example/api/strava/callback", + "response_type": "code", + "scope": "activity:read_all", + "state": "state-nonce-1" + } + }, + "exchange": { + "ok": { + "result": { + "access_token": "access-1", + "refresh_token": "refresh-1", + "expires_in": 3600, + "token_type": "Bearer", + "scope": "granted" + }, + "requests": [ + { + "method": "POST", + "url": "https://www.strava.com/oauth/token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "code": "code-1", + "grant_type": "authorization_code" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "strava", + "method": "exchangeCode", + "duration_ms": 0, + "status": 200 + } + ] + } + ], + "unansweredScript": 0 + }, + "empty_body": { + "error": { + "name": "StravaApiError", + "message": "Strava exchangeCode error: 200", + "classification": "transient", + "httpStatus": 200, + "reason": "empty_token_body", + "verb": "exchangeCode", + "vendor": "Strava" + }, + "requests": [ + { + "method": "POST", + "url": "https://www.strava.com/oauth/token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "code": "code-1", + "grant_type": "authorization_code" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "strava", + "method": "exchangeCode", + "duration_ms": 0, + "status": 200 + } + ] + } + ], + "unansweredScript": 0 + }, + "invalid_grant": { + "error": { + "name": "StravaApiError", + "message": "Strava exchangeCode error: 400 - invalid_grant", + "classification": "persistent", + "httpStatus": 400, + "reason": "http_400", + "verb": "exchangeCode", + "upstreamError": "invalid_grant", + "vendor": "Strava" + }, + "requests": [ + { + "method": "POST", + "url": "https://www.strava.com/oauth/token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "code": "code-1", + "grant_type": "authorization_code" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "strava", + "method": "exchangeCode", + "duration_ms": 0, + "status": 400, + "error": "http_400" + } + ] + } + ], + "unansweredScript": 0 + }, + "strava_fault": { + "error": { + "name": "StravaApiError", + "message": "Strava exchangeCode error: 400 - RefreshToken.refresh_token:invalid", + "classification": "persistent", + "httpStatus": 400, + "reason": "http_400", + "verb": "exchangeCode", + "upstreamError": "RefreshToken.refresh_token:invalid", + "vendor": "Strava" + }, + "requests": [ + { + "method": "POST", + "url": "https://www.strava.com/oauth/token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "code": "code-1", + "grant_type": "authorization_code" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "strava", + "method": "exchangeCode", + "duration_ms": 0, + "status": 400, + "error": "http_400" + } + ] + } + ], + "unansweredScript": 0 + }, + "invalid_client": { + "error": { + "name": "StravaApiError", + "message": "Strava exchangeCode error: 401 - invalid_client", + "classification": "reauth_required", + "httpStatus": 401, + "reason": "http_401", + "verb": "exchangeCode", + "upstreamError": "invalid_client", + "vendor": "Strava" + }, + "requests": [ + { + "method": "POST", + "url": "https://www.strava.com/oauth/token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "code": "code-1", + "grant_type": "authorization_code" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "strava", + "method": "exchangeCode", + "duration_ms": 0, + "status": 401, + "error": "http_401" + } + ] + } + ], + "unansweredScript": 0 + }, + "rate_limited": { + "error": { + "name": "StravaApiError", + "message": "Strava exchangeCode error: 429 - rate_limited", + "classification": "transient", + "httpStatus": 429, + "reason": "http_429", + "verb": "exchangeCode", + "upstreamError": "rate_limited", + "vendor": "Strava" + }, + "requests": [ + { + "method": "POST", + "url": "https://www.strava.com/oauth/token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "code": "code-1", + "grant_type": "authorization_code" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "strava", + "method": "exchangeCode", + "duration_ms": 0, + "status": 429, + "error": "http_429" + } + ] + } + ], + "unansweredScript": 0 + }, + "outage": { + "error": { + "name": "StravaApiError", + "message": "Strava exchangeCode error: 503", + "classification": "transient", + "httpStatus": 503, + "reason": "http_503", + "verb": "exchangeCode", + "vendor": "Strava" + }, + "requests": [ + { + "method": "POST", + "url": "https://www.strava.com/oauth/token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "code": "code-1", + "grant_type": "authorization_code" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "strava", + "method": "exchangeCode", + "duration_ms": 0, + "status": 503, + "error": "http_503" + } + ] + } + ], + "unansweredScript": 0 + } + }, + "refresh": { + "ok": { + "result": { + "access_token": "access-1", + "refresh_token": "refresh-1", + "expires_in": 3600, + "token_type": "Bearer", + "scope": "granted" + }, + "requests": [ + { + "method": "POST", + "url": "https://www.strava.com/oauth/token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "strava", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 200 + } + ] + } + ], + "unansweredScript": 0 + }, + "empty_body": { + "error": { + "name": "StravaApiError", + "message": "Strava refreshAccessToken error: 200", + "classification": "transient", + "httpStatus": 200, + "reason": "empty_token_body", + "verb": "refreshAccessToken", + "vendor": "Strava" + }, + "requests": [ + { + "method": "POST", + "url": "https://www.strava.com/oauth/token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "strava", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 200 + } + ] + } + ], + "unansweredScript": 0 + }, + "invalid_grant": { + "error": { + "name": "StravaApiError", + "message": "Strava refreshAccessToken error: 400 - invalid_grant", + "classification": "persistent", + "httpStatus": 400, + "reason": "http_400", + "verb": "refreshAccessToken", + "upstreamError": "invalid_grant", + "vendor": "Strava" + }, + "requests": [ + { + "method": "POST", + "url": "https://www.strava.com/oauth/token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "strava", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 400, + "error": "http_400" + } + ] + } + ], + "unansweredScript": 0 + }, + "strava_fault": { + "error": { + "name": "StravaApiError", + "message": "Strava refreshAccessToken error: 400 - RefreshToken.refresh_token:invalid", + "classification": "persistent", + "httpStatus": 400, + "reason": "http_400", + "verb": "refreshAccessToken", + "upstreamError": "RefreshToken.refresh_token:invalid", + "vendor": "Strava" + }, + "requests": [ + { + "method": "POST", + "url": "https://www.strava.com/oauth/token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "strava", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 400, + "error": "http_400" + } + ] + } + ], + "unansweredScript": 0 + }, + "invalid_client": { + "error": { + "name": "StravaApiError", + "message": "Strava refreshAccessToken error: 401 - invalid_client", + "classification": "reauth_required", + "httpStatus": 401, + "reason": "http_401", + "verb": "refreshAccessToken", + "upstreamError": "invalid_client", + "vendor": "Strava" + }, + "requests": [ + { + "method": "POST", + "url": "https://www.strava.com/oauth/token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "strava", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 401, + "error": "http_401" + } + ] + } + ], + "unansweredScript": 0 + }, + "rate_limited": { + "error": { + "name": "StravaApiError", + "message": "Strava refreshAccessToken error: 429 - rate_limited", + "classification": "transient", + "httpStatus": 429, + "reason": "http_429", + "verb": "refreshAccessToken", + "upstreamError": "rate_limited", + "vendor": "Strava" + }, + "requests": [ + { + "method": "POST", + "url": "https://www.strava.com/oauth/token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "strava", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 429, + "error": "http_429" + } + ] + } + ], + "unansweredScript": 0 + }, + "outage": { + "error": { + "name": "StravaApiError", + "message": "Strava refreshAccessToken error: 503", + "classification": "transient", + "httpStatus": 503, + "reason": "http_503", + "verb": "refreshAccessToken", + "vendor": "Strava" + }, + "requests": [ + { + "method": "POST", + "url": "https://www.strava.com/oauth/token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "strava", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 503, + "error": "http_503" + } + ] + } + ], + "unansweredScript": 0 + } + }, + "deauthorize_ok": { + "result": null, + "requests": [ + { + "method": "POST", + "url": "https://www.strava.com/oauth/deauthorize", + "query": {}, + "headers": { + "Accept": "application/json", + "Authorization": "Bearer access-1" + }, + "form": null, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "strava", + "method": "deauthorize", + "duration_ms": 0, + "status": 200 + } + ] + } + ], + "unansweredScript": 0 + }, + "deauthorize_refused": { + "result": null, + "requests": [ + { + "method": "POST", + "url": "https://www.strava.com/oauth/deauthorize", + "query": {}, + "headers": { + "Accept": "application/json", + "Authorization": "Bearer access-1" + }, + "form": null, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "strava", + "method": "deauthorize", + "duration_ms": 0, + "status": 401, + "error": "http_401" + } + ] + } + ], + "unansweredScript": 0 + } + }, + "oura": { + "redirectUri": "https://healthlog.example/api/oura/callback", + "authorize": { + "url": "https://cloud.ouraring.com/oauth/authorize", + "query": { + "client_id": "client-id-1", + "redirect_uri": "https://healthlog.example/api/oura/callback", + "response_type": "code", + "scope": "daily spo2 heart_health stress workout heartrate personal", + "state": "state-nonce-1" + } + }, + "exchange": { + "ok": { + "result": { + "access_token": "access-1", + "refresh_token": "refresh-1", + "expires_in": 3600, + "token_type": "Bearer", + "scope": "granted" + }, + "requests": [ + { + "method": "POST", + "url": "https://moi.ouraring.com/oauth/v2/ext/oauth-token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "code": "code-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/oura/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "oura", + "method": "exchangeCode", + "duration_ms": 0, + "status": 200 + } + ] + } + ], + "unansweredScript": 0 + }, + "empty_body": { + "error": { + "name": "OuraApiError", + "message": "Oura exchangeCode error: 200", + "classification": "transient", + "httpStatus": 200, + "reason": "empty_token_body", + "verb": "exchangeCode", + "vendor": "Oura" + }, + "requests": [ + { + "method": "POST", + "url": "https://moi.ouraring.com/oauth/v2/ext/oauth-token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "code": "code-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/oura/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "oura", + "method": "exchangeCode", + "duration_ms": 0, + "status": 200 + } + ] + } + ], + "unansweredScript": 0 + }, + "invalid_grant": { + "error": { + "name": "OuraApiError", + "message": "Oura exchangeCode error: 400 - invalid_grant", + "classification": "persistent", + "httpStatus": 400, + "reason": "http_400", + "verb": "exchangeCode", + "upstreamError": "invalid_grant", + "vendor": "Oura" + }, + "requests": [ + { + "method": "POST", + "url": "https://moi.ouraring.com/oauth/v2/ext/oauth-token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "code": "code-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/oura/callback" + }, + "body": null + }, + { + "method": "POST", + "url": "https://api.ouraring.com/oauth/token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "code": "code-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/oura/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "oura", + "method": "exchangeCode", + "duration_ms": 0, + "status": 400, + "error": "http_400" + } + ] + }, + { + "method": "addExternalCall", + "args": [ + { + "service": "oura", + "method": "exchangeCode.legacy", + "duration_ms": 0, + "status": 599, + "error": "http_599" + } + ] + } + ], + "unansweredScript": 0 + }, + "strava_fault": { + "error": { + "name": "OuraApiError", + "message": "Oura exchangeCode error: 400", + "classification": "persistent", + "httpStatus": 400, + "reason": "http_400", + "verb": "exchangeCode", + "vendor": "Oura" + }, + "requests": [ + { + "method": "POST", + "url": "https://moi.ouraring.com/oauth/v2/ext/oauth-token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "code": "code-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/oura/callback" + }, + "body": null + }, + { + "method": "POST", + "url": "https://api.ouraring.com/oauth/token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "code": "code-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/oura/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "oura", + "method": "exchangeCode", + "duration_ms": 0, + "status": 400, + "error": "http_400" + } + ] + }, + { + "method": "addExternalCall", + "args": [ + { + "service": "oura", + "method": "exchangeCode.legacy", + "duration_ms": 0, + "status": 599, + "error": "http_599" + } + ] + } + ], + "unansweredScript": 0 + }, + "invalid_client": { + "error": { + "name": "OuraApiError", + "message": "Oura exchangeCode error: 401 - invalid_client", + "classification": "reauth_required", + "httpStatus": 401, + "reason": "http_401", + "verb": "exchangeCode", + "upstreamError": "invalid_client", + "vendor": "Oura" + }, + "requests": [ + { + "method": "POST", + "url": "https://moi.ouraring.com/oauth/v2/ext/oauth-token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "code": "code-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/oura/callback" + }, + "body": null + }, + { + "method": "POST", + "url": "https://api.ouraring.com/oauth/token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "code": "code-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/oura/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "oura", + "method": "exchangeCode", + "duration_ms": 0, + "status": 401, + "error": "http_401" + } + ] + }, + { + "method": "addExternalCall", + "args": [ + { + "service": "oura", + "method": "exchangeCode.legacy", + "duration_ms": 0, + "status": 599, + "error": "http_599" + } + ] + } + ], + "unansweredScript": 0 + }, + "rate_limited": { + "error": { + "name": "OuraApiError", + "message": "Oura exchangeCode error: 429 - rate_limited", + "classification": "transient", + "httpStatus": 429, + "reason": "http_429", + "verb": "exchangeCode", + "upstreamError": "rate_limited", + "vendor": "Oura" + }, + "requests": [ + { + "method": "POST", + "url": "https://moi.ouraring.com/oauth/v2/ext/oauth-token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "code": "code-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/oura/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "oura", + "method": "exchangeCode", + "duration_ms": 0, + "status": 429, + "error": "http_429" + } + ] + } + ], + "unansweredScript": 0 + }, + "outage": { + "error": { + "name": "OuraApiError", + "message": "Oura exchangeCode error: 503", + "classification": "transient", + "httpStatus": 503, + "reason": "http_503", + "verb": "exchangeCode", + "vendor": "Oura" + }, + "requests": [ + { + "method": "POST", + "url": "https://moi.ouraring.com/oauth/v2/ext/oauth-token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "code": "code-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/oura/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "oura", + "method": "exchangeCode", + "duration_ms": 0, + "status": 503, + "error": "http_503" + } + ] + } + ], + "unansweredScript": 0 + } + }, + "refresh": { + "ok": { + "result": { + "access_token": "access-1", + "refresh_token": "refresh-1", + "expires_in": 3600, + "token_type": "Bearer", + "scope": "granted" + }, + "requests": [ + { + "method": "POST", + "url": "https://moi.ouraring.com/oauth/v2/ext/oauth-token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "oura", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 200 + } + ] + } + ], + "unansweredScript": 0 + }, + "empty_body": { + "error": { + "name": "OuraApiError", + "message": "Oura refreshAccessToken error: 200", + "classification": "transient", + "httpStatus": 200, + "reason": "empty_token_body", + "verb": "refreshAccessToken", + "vendor": "Oura" + }, + "requests": [ + { + "method": "POST", + "url": "https://moi.ouraring.com/oauth/v2/ext/oauth-token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "oura", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 200 + } + ] + } + ], + "unansweredScript": 0 + }, + "invalid_grant": { + "error": { + "name": "OuraApiError", + "message": "Oura refreshAccessToken error: 400 - invalid_grant", + "classification": "persistent", + "httpStatus": 400, + "reason": "http_400", + "verb": "refreshAccessToken", + "upstreamError": "invalid_grant", + "vendor": "Oura" + }, + "requests": [ + { + "method": "POST", + "url": "https://moi.ouraring.com/oauth/v2/ext/oauth-token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + }, + { + "method": "POST", + "url": "https://api.ouraring.com/oauth/token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "oura", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 400, + "error": "http_400" + } + ] + }, + { + "method": "addExternalCall", + "args": [ + { + "service": "oura", + "method": "refreshAccessToken.legacy", + "duration_ms": 0, + "status": 599, + "error": "http_599" + } + ] + } + ], + "unansweredScript": 0 + }, + "strava_fault": { + "error": { + "name": "OuraApiError", + "message": "Oura refreshAccessToken error: 400", + "classification": "persistent", + "httpStatus": 400, + "reason": "http_400", + "verb": "refreshAccessToken", + "vendor": "Oura" + }, + "requests": [ + { + "method": "POST", + "url": "https://moi.ouraring.com/oauth/v2/ext/oauth-token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + }, + { + "method": "POST", + "url": "https://api.ouraring.com/oauth/token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "oura", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 400, + "error": "http_400" + } + ] + }, + { + "method": "addExternalCall", + "args": [ + { + "service": "oura", + "method": "refreshAccessToken.legacy", + "duration_ms": 0, + "status": 599, + "error": "http_599" + } + ] + } + ], + "unansweredScript": 0 + }, + "invalid_client": { + "error": { + "name": "OuraApiError", + "message": "Oura refreshAccessToken error: 401 - invalid_client", + "classification": "reauth_required", + "httpStatus": 401, + "reason": "http_401", + "verb": "refreshAccessToken", + "upstreamError": "invalid_client", + "vendor": "Oura" + }, + "requests": [ + { + "method": "POST", + "url": "https://moi.ouraring.com/oauth/v2/ext/oauth-token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + }, + { + "method": "POST", + "url": "https://api.ouraring.com/oauth/token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "oura", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 401, + "error": "http_401" + } + ] + }, + { + "method": "addExternalCall", + "args": [ + { + "service": "oura", + "method": "refreshAccessToken.legacy", + "duration_ms": 0, + "status": 599, + "error": "http_599" + } + ] + } + ], + "unansweredScript": 0 + }, + "rate_limited": { + "error": { + "name": "OuraApiError", + "message": "Oura refreshAccessToken error: 429 - rate_limited", + "classification": "transient", + "httpStatus": 429, + "reason": "http_429", + "verb": "refreshAccessToken", + "upstreamError": "rate_limited", + "vendor": "Oura" + }, + "requests": [ + { + "method": "POST", + "url": "https://moi.ouraring.com/oauth/v2/ext/oauth-token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "oura", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 429, + "error": "http_429" + } + ] + } + ], + "unansweredScript": 0 + }, + "outage": { + "error": { + "name": "OuraApiError", + "message": "Oura refreshAccessToken error: 503", + "classification": "transient", + "httpStatus": 503, + "reason": "http_503", + "verb": "refreshAccessToken", + "vendor": "Oura" + }, + "requests": [ + { + "method": "POST", + "url": "https://moi.ouraring.com/oauth/v2/ext/oauth-token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "oura", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 503, + "error": "http_503" + } + ] + } + ], + "unansweredScript": 0 + } + }, + "exchange_from_moi_refused": { + "error": { + "name": "OuraApiError", + "message": "Oura exchangeCode error: 400 - invalid_grant", + "classification": "persistent", + "httpStatus": 400, + "reason": "http_400", + "verb": "exchangeCode", + "upstreamError": "invalid_grant", + "vendor": "Oura" + }, + "requests": [ + { + "method": "POST", + "url": "https://moi.ouraring.com/oauth/v2/ext/oauth-token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "code": "code-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/oura/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "oura", + "method": "exchangeCode", + "duration_ms": 0, + "status": 400, + "error": "http_400" + } + ] + } + ], + "unansweredScript": 0 + }, + "exchange_legacy_fallback": { + "result": { + "access_token": "access-1", + "refresh_token": "refresh-1", + "expires_in": 3600, + "token_type": "Bearer", + "scope": "granted" + }, + "requests": [ + { + "method": "POST", + "url": "https://moi.ouraring.com/oauth/v2/ext/oauth-token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "code": "code-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/oura/callback" + }, + "body": null + }, + { + "method": "POST", + "url": "https://api.ouraring.com/oauth/token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "code": "code-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/oura/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "oura", + "method": "exchangeCode", + "duration_ms": 0, + "status": 400, + "error": "http_400" + } + ] + }, + { + "method": "addExternalCall", + "args": [ + { + "service": "oura", + "method": "exchangeCode.legacy", + "duration_ms": 0, + "status": 200 + } + ] + } + ], + "unansweredScript": 0 + }, + "exchange_both_refused": { + "error": { + "name": "OuraApiError", + "message": "Oura exchangeCode error: 401 - invalid_client", + "classification": "reauth_required", + "httpStatus": 401, + "reason": "http_401", + "verb": "exchangeCode", + "upstreamError": "invalid_client", + "vendor": "Oura" + }, + "requests": [ + { + "method": "POST", + "url": "https://moi.ouraring.com/oauth/v2/ext/oauth-token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "code": "code-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/oura/callback" + }, + "body": null + }, + { + "method": "POST", + "url": "https://api.ouraring.com/oauth/token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "code": "code-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/oura/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "oura", + "method": "exchangeCode", + "duration_ms": 0, + "status": 401, + "error": "http_401" + } + ] + }, + { + "method": "addExternalCall", + "args": [ + { + "service": "oura", + "method": "exchangeCode.legacy", + "duration_ms": 0, + "status": 400, + "error": "http_400" + } + ] + } + ], + "unansweredScript": 0 + }, + "refresh_legacy_fallback": { + "result": { + "access_token": "access-1", + "refresh_token": "refresh-1", + "expires_in": 3600, + "token_type": "Bearer", + "scope": "granted" + }, + "requests": [ + { + "method": "POST", + "url": "https://moi.ouraring.com/oauth/v2/ext/oauth-token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + }, + { + "method": "POST", + "url": "https://api.ouraring.com/oauth/token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "oura", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 404, + "error": "http_404" + } + ] + }, + { + "method": "addExternalCall", + "args": [ + { + "service": "oura", + "method": "refreshAccessToken.legacy", + "duration_ms": 0, + "status": 200 + } + ] + } + ], + "unansweredScript": 0 + }, + "refresh_outage_no_fallback": { + "error": { + "name": "OuraApiError", + "message": "Oura refreshAccessToken error: 502", + "classification": "transient", + "httpStatus": 502, + "reason": "http_502", + "verb": "refreshAccessToken", + "vendor": "Oura" + }, + "requests": [ + { + "method": "POST", + "url": "https://moi.ouraring.com/oauth/v2/ext/oauth-token", + "query": {}, + "headers": { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "client_secret": "client-secret-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "oura", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 502, + "error": "http_502" + } + ] + } + ], + "unansweredScript": 0 + } + }, + "polar": { + "redirectUri": "https://healthlog.example/api/polar/callback", + "authorize": { + "url": "https://flow.polar.com/oauth2/authorization", + "query": { + "client_id": "client-id-1", + "redirect_uri": "https://healthlog.example/api/polar/callback", + "response_type": "code", + "scope": "accesslink.read_all", + "state": "state-nonce-1" + } + }, + "exchange": { + "ok": { + "result": { + "access_token": "access-1", + "refresh_token": "refresh-1", + "expires_in": 3600, + "token_type": "Bearer", + "scope": "granted" + }, + "requests": [ + { + "method": "POST", + "url": "https://polarremote.com/v2/oauth2/token", + "query": {}, + "headers": { + "Accept": "application/json", + "Authorization": "Basic Y2xpZW50LWlkLTE6Y2xpZW50LXNlY3JldC0x", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "code": "code-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/polar/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "polar", + "method": "exchangeCode", + "duration_ms": 0, + "status": 200 + } + ] + } + ], + "unansweredScript": 0 + }, + "empty_body": { + "error": { + "name": "PolarApiError", + "message": "Polar exchangeCode error: 200", + "classification": "transient", + "httpStatus": 200, + "reason": "empty_token_body", + "verb": "exchangeCode", + "vendor": "Polar" + }, + "requests": [ + { + "method": "POST", + "url": "https://polarremote.com/v2/oauth2/token", + "query": {}, + "headers": { + "Accept": "application/json", + "Authorization": "Basic Y2xpZW50LWlkLTE6Y2xpZW50LXNlY3JldC0x", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "code": "code-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/polar/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "polar", + "method": "exchangeCode", + "duration_ms": 0, + "status": 200 + } + ] + } + ], + "unansweredScript": 0 + }, + "invalid_grant": { + "error": { + "name": "PolarApiError", + "message": "Polar exchangeCode error: 400 - invalid_grant", + "classification": "persistent", + "httpStatus": 400, + "reason": "http_400", + "verb": "exchangeCode", + "upstreamError": "invalid_grant", + "vendor": "Polar" + }, + "requests": [ + { + "method": "POST", + "url": "https://polarremote.com/v2/oauth2/token", + "query": {}, + "headers": { + "Accept": "application/json", + "Authorization": "Basic Y2xpZW50LWlkLTE6Y2xpZW50LXNlY3JldC0x", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "code": "code-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/polar/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "polar", + "method": "exchangeCode", + "duration_ms": 0, + "status": 400, + "error": "http_400" + } + ] + } + ], + "unansweredScript": 0 + }, + "strava_fault": { + "error": { + "name": "PolarApiError", + "message": "Polar exchangeCode error: 400", + "classification": "persistent", + "httpStatus": 400, + "reason": "http_400", + "verb": "exchangeCode", + "vendor": "Polar" + }, + "requests": [ + { + "method": "POST", + "url": "https://polarremote.com/v2/oauth2/token", + "query": {}, + "headers": { + "Accept": "application/json", + "Authorization": "Basic Y2xpZW50LWlkLTE6Y2xpZW50LXNlY3JldC0x", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "code": "code-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/polar/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "polar", + "method": "exchangeCode", + "duration_ms": 0, + "status": 400, + "error": "http_400" + } + ] + } + ], + "unansweredScript": 0 + }, + "invalid_client": { + "error": { + "name": "PolarApiError", + "message": "Polar exchangeCode error: 401 - invalid_client", + "classification": "reauth_required", + "httpStatus": 401, + "reason": "http_401", + "verb": "exchangeCode", + "upstreamError": "invalid_client", + "vendor": "Polar" + }, + "requests": [ + { + "method": "POST", + "url": "https://polarremote.com/v2/oauth2/token", + "query": {}, + "headers": { + "Accept": "application/json", + "Authorization": "Basic Y2xpZW50LWlkLTE6Y2xpZW50LXNlY3JldC0x", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "code": "code-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/polar/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "polar", + "method": "exchangeCode", + "duration_ms": 0, + "status": 401, + "error": "http_401" + } + ] + } + ], + "unansweredScript": 0 + }, + "rate_limited": { + "error": { + "name": "PolarApiError", + "message": "Polar exchangeCode error: 429 - rate_limited", + "classification": "transient", + "httpStatus": 429, + "reason": "http_429", + "verb": "exchangeCode", + "upstreamError": "rate_limited", + "vendor": "Polar" + }, + "requests": [ + { + "method": "POST", + "url": "https://polarremote.com/v2/oauth2/token", + "query": {}, + "headers": { + "Accept": "application/json", + "Authorization": "Basic Y2xpZW50LWlkLTE6Y2xpZW50LXNlY3JldC0x", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "code": "code-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/polar/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "polar", + "method": "exchangeCode", + "duration_ms": 0, + "status": 429, + "error": "http_429" + } + ] + } + ], + "unansweredScript": 0 + }, + "outage": { + "error": { + "name": "PolarApiError", + "message": "Polar exchangeCode error: 503", + "classification": "transient", + "httpStatus": 503, + "reason": "http_503", + "verb": "exchangeCode", + "vendor": "Polar" + }, + "requests": [ + { + "method": "POST", + "url": "https://polarremote.com/v2/oauth2/token", + "query": {}, + "headers": { + "Accept": "application/json", + "Authorization": "Basic Y2xpZW50LWlkLTE6Y2xpZW50LXNlY3JldC0x", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "code": "code-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/polar/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "polar", + "method": "exchangeCode", + "duration_ms": 0, + "status": 503, + "error": "http_503" + } + ] + } + ], + "unansweredScript": 0 + } + } + }, + "google-health": { + "redirectUri": "https://healthlog.example/api/google-health/callback", + "authorize": { + "url": "https://accounts.google.com/o/oauth2/v2/auth", + "query": { + "access_type": "offline", + "client_id": "client-id-1", + "code_challenge": "pkce-challenge-1", + "code_challenge_method": "S256", + "prompt": "consent", + "redirect_uri": "https://healthlog.example/api/google-health/callback", + "response_type": "code", + "scope": "https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly https://www.googleapis.com/auth/googlehealth.sleep.readonly https://www.googleapis.com/auth/googlehealth.profile.readonly", + "state": "state-nonce-1" + } + }, + "exchange": { + "ok": { + "result": { + "access_token": "access-1", + "refresh_token": "refresh-1", + "expires_in": 3600, + "token_type": "Bearer", + "scope": "granted" + }, + "requests": [ + { + "method": "POST", + "url": "https://oauth2.googleapis.com/token", + "query": {}, + "headers": { + "Authorization": "Basic Y2xpZW50LWlkLTE6Y2xpZW50LXNlY3JldC0x", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "code": "code-1", + "code_verifier": "pkce-verifier-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/google-health/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "google-health", + "method": "exchangeCode", + "duration_ms": 0, + "status": 200 + } + ] + } + ], + "unansweredScript": 0 + }, + "empty_body": { + "error": { + "name": "GoogleHealthApiError", + "message": "GoogleHealth exchangeCode error: 200", + "classification": "transient", + "httpStatus": 200, + "reason": "empty_token_body", + "verb": "exchangeCode", + "vendor": "GoogleHealth" + }, + "requests": [ + { + "method": "POST", + "url": "https://oauth2.googleapis.com/token", + "query": {}, + "headers": { + "Authorization": "Basic Y2xpZW50LWlkLTE6Y2xpZW50LXNlY3JldC0x", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "code": "code-1", + "code_verifier": "pkce-verifier-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/google-health/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "google-health", + "method": "exchangeCode", + "duration_ms": 0, + "status": 200 + } + ] + } + ], + "unansweredScript": 0 + }, + "invalid_grant": { + "error": { + "name": "GoogleHealthApiError", + "message": "GoogleHealth exchangeCode error: 400 - invalid_grant", + "classification": "reauth_required", + "httpStatus": 400, + "reason": "http_400", + "verb": "exchangeCode", + "upstreamError": "invalid_grant", + "vendor": "GoogleHealth" + }, + "requests": [ + { + "method": "POST", + "url": "https://oauth2.googleapis.com/token", + "query": {}, + "headers": { + "Authorization": "Basic Y2xpZW50LWlkLTE6Y2xpZW50LXNlY3JldC0x", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "code": "code-1", + "code_verifier": "pkce-verifier-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/google-health/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "google-health", + "method": "exchangeCode", + "duration_ms": 0, + "status": 400, + "error": "http_400" + } + ] + } + ], + "unansweredScript": 0 + }, + "strava_fault": { + "error": { + "name": "GoogleHealthApiError", + "message": "GoogleHealth exchangeCode error: 400", + "classification": "persistent", + "httpStatus": 400, + "reason": "http_400", + "verb": "exchangeCode", + "vendor": "GoogleHealth" + }, + "requests": [ + { + "method": "POST", + "url": "https://oauth2.googleapis.com/token", + "query": {}, + "headers": { + "Authorization": "Basic Y2xpZW50LWlkLTE6Y2xpZW50LXNlY3JldC0x", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "code": "code-1", + "code_verifier": "pkce-verifier-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/google-health/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "google-health", + "method": "exchangeCode", + "duration_ms": 0, + "status": 400, + "error": "http_400" + } + ] + } + ], + "unansweredScript": 0 + }, + "invalid_client": { + "error": { + "name": "GoogleHealthApiError", + "message": "GoogleHealth exchangeCode error: 401 - invalid_client", + "classification": "reauth_required", + "httpStatus": 401, + "reason": "http_401", + "verb": "exchangeCode", + "upstreamError": "invalid_client", + "vendor": "GoogleHealth" + }, + "requests": [ + { + "method": "POST", + "url": "https://oauth2.googleapis.com/token", + "query": {}, + "headers": { + "Authorization": "Basic Y2xpZW50LWlkLTE6Y2xpZW50LXNlY3JldC0x", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "code": "code-1", + "code_verifier": "pkce-verifier-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/google-health/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "google-health", + "method": "exchangeCode", + "duration_ms": 0, + "status": 401, + "error": "http_401" + } + ] + } + ], + "unansweredScript": 0 + }, + "rate_limited": { + "error": { + "name": "GoogleHealthApiError", + "message": "GoogleHealth exchangeCode error: 429 - rate_limited", + "classification": "transient", + "httpStatus": 429, + "reason": "http_429", + "verb": "exchangeCode", + "upstreamError": "rate_limited", + "vendor": "GoogleHealth" + }, + "requests": [ + { + "method": "POST", + "url": "https://oauth2.googleapis.com/token", + "query": {}, + "headers": { + "Authorization": "Basic Y2xpZW50LWlkLTE6Y2xpZW50LXNlY3JldC0x", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "code": "code-1", + "code_verifier": "pkce-verifier-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/google-health/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "google-health", + "method": "exchangeCode", + "duration_ms": 0, + "status": 429, + "error": "http_429" + } + ] + } + ], + "unansweredScript": 0 + }, + "outage": { + "error": { + "name": "GoogleHealthApiError", + "message": "GoogleHealth exchangeCode error: 503", + "classification": "transient", + "httpStatus": 503, + "reason": "http_503", + "verb": "exchangeCode", + "vendor": "GoogleHealth" + }, + "requests": [ + { + "method": "POST", + "url": "https://oauth2.googleapis.com/token", + "query": {}, + "headers": { + "Authorization": "Basic Y2xpZW50LWlkLTE6Y2xpZW50LXNlY3JldC0x", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "code": "code-1", + "code_verifier": "pkce-verifier-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/google-health/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "google-health", + "method": "exchangeCode", + "duration_ms": 0, + "status": 503, + "error": "http_503" + } + ] + } + ], + "unansweredScript": 0 + } + }, + "refresh": { + "ok": { + "result": { + "access_token": "access-1", + "refresh_token": "refresh-1", + "expires_in": 3600, + "token_type": "Bearer", + "scope": "granted" + }, + "requests": [ + { + "method": "POST", + "url": "https://oauth2.googleapis.com/token", + "query": {}, + "headers": { + "Authorization": "Basic Y2xpZW50LWlkLTE6Y2xpZW50LXNlY3JldC0x", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "google-health", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 200 + } + ] + } + ], + "unansweredScript": 0 + }, + "empty_body": { + "error": { + "name": "GoogleHealthApiError", + "message": "GoogleHealth refreshAccessToken error: 200", + "classification": "transient", + "httpStatus": 200, + "reason": "empty_token_body", + "verb": "refreshAccessToken", + "vendor": "GoogleHealth" + }, + "requests": [ + { + "method": "POST", + "url": "https://oauth2.googleapis.com/token", + "query": {}, + "headers": { + "Authorization": "Basic Y2xpZW50LWlkLTE6Y2xpZW50LXNlY3JldC0x", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "google-health", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 200 + } + ] + } + ], + "unansweredScript": 0 + }, + "invalid_grant": { + "error": { + "name": "GoogleHealthApiError", + "message": "GoogleHealth refreshAccessToken error: 400 - invalid_grant", + "classification": "reauth_required", + "httpStatus": 400, + "reason": "http_400", + "verb": "refreshAccessToken", + "upstreamError": "invalid_grant", + "vendor": "GoogleHealth" + }, + "requests": [ + { + "method": "POST", + "url": "https://oauth2.googleapis.com/token", + "query": {}, + "headers": { + "Authorization": "Basic Y2xpZW50LWlkLTE6Y2xpZW50LXNlY3JldC0x", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "google-health", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 400, + "error": "http_400" + } + ] + } + ], + "unansweredScript": 0 + }, + "strava_fault": { + "error": { + "name": "GoogleHealthApiError", + "message": "GoogleHealth refreshAccessToken error: 400", + "classification": "persistent", + "httpStatus": 400, + "reason": "http_400", + "verb": "refreshAccessToken", + "vendor": "GoogleHealth" + }, + "requests": [ + { + "method": "POST", + "url": "https://oauth2.googleapis.com/token", + "query": {}, + "headers": { + "Authorization": "Basic Y2xpZW50LWlkLTE6Y2xpZW50LXNlY3JldC0x", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "google-health", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 400, + "error": "http_400" + } + ] + } + ], + "unansweredScript": 0 + }, + "invalid_client": { + "error": { + "name": "GoogleHealthApiError", + "message": "GoogleHealth refreshAccessToken error: 401 - invalid_client", + "classification": "reauth_required", + "httpStatus": 401, + "reason": "http_401", + "verb": "refreshAccessToken", + "upstreamError": "invalid_client", + "vendor": "GoogleHealth" + }, + "requests": [ + { + "method": "POST", + "url": "https://oauth2.googleapis.com/token", + "query": {}, + "headers": { + "Authorization": "Basic Y2xpZW50LWlkLTE6Y2xpZW50LXNlY3JldC0x", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "google-health", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 401, + "error": "http_401" + } + ] + } + ], + "unansweredScript": 0 + }, + "rate_limited": { + "error": { + "name": "GoogleHealthApiError", + "message": "GoogleHealth refreshAccessToken error: 429 - rate_limited", + "classification": "transient", + "httpStatus": 429, + "reason": "http_429", + "verb": "refreshAccessToken", + "upstreamError": "rate_limited", + "vendor": "GoogleHealth" + }, + "requests": [ + { + "method": "POST", + "url": "https://oauth2.googleapis.com/token", + "query": {}, + "headers": { + "Authorization": "Basic Y2xpZW50LWlkLTE6Y2xpZW50LXNlY3JldC0x", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "google-health", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 429, + "error": "http_429" + } + ] + } + ], + "unansweredScript": 0 + }, + "outage": { + "error": { + "name": "GoogleHealthApiError", + "message": "GoogleHealth refreshAccessToken error: 503", + "classification": "transient", + "httpStatus": 503, + "reason": "http_503", + "verb": "refreshAccessToken", + "vendor": "GoogleHealth" + }, + "requests": [ + { + "method": "POST", + "url": "https://oauth2.googleapis.com/token", + "query": {}, + "headers": { + "Authorization": "Basic Y2xpZW50LWlkLTE6Y2xpZW50LXNlY3JldC0x", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "google-health", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 503, + "error": "http_503" + } + ] + } + ], + "unansweredScript": 0 + } + } + }, + "fitbit": { + "redirectUri": "https://healthlog.example/api/fitbit/callback", + "authorize": { + "url": "https://www.fitbit.com/oauth2/authorize", + "query": { + "client_id": "client-id-1", + "code_challenge": "pkce-challenge-1", + "code_challenge_method": "S256", + "redirect_uri": "https://healthlog.example/api/fitbit/callback", + "response_type": "code", + "scope": "activity cardio_fitness heartrate oxygen_saturation profile respiratory_rate sleep weight", + "state": "state-nonce-1" + } + }, + "exchange": { + "ok": { + "result": { + "access_token": "access-1", + "refresh_token": "refresh-1", + "expires_in": 3600, + "token_type": "Bearer", + "scope": "granted" + }, + "requests": [ + { + "method": "POST", + "url": "https://api.fitbit.com/oauth2/token", + "query": {}, + "headers": { + "Authorization": "Basic Y2xpZW50LWlkLTE6Y2xpZW50LXNlY3JldC0x", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "code": "code-1", + "code_verifier": "pkce-verifier-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/fitbit/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "fitbit", + "method": "exchangeCode", + "duration_ms": 0, + "status": 200 + } + ] + } + ], + "unansweredScript": 0 + }, + "empty_body": { + "error": { + "name": "FitbitApiError", + "message": "Fitbit exchangeCode error: 200", + "classification": "transient", + "httpStatus": 200, + "reason": "empty_token_body", + "verb": "exchangeCode", + "vendor": "Fitbit" + }, + "requests": [ + { + "method": "POST", + "url": "https://api.fitbit.com/oauth2/token", + "query": {}, + "headers": { + "Authorization": "Basic Y2xpZW50LWlkLTE6Y2xpZW50LXNlY3JldC0x", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "code": "code-1", + "code_verifier": "pkce-verifier-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/fitbit/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "fitbit", + "method": "exchangeCode", + "duration_ms": 0, + "status": 200 + } + ] + } + ], + "unansweredScript": 0 + }, + "invalid_grant": { + "error": { + "name": "FitbitApiError", + "message": "Fitbit exchangeCode error: 400 - invalid_grant", + "classification": "persistent", + "httpStatus": 400, + "reason": "http_400", + "verb": "exchangeCode", + "upstreamError": "invalid_grant", + "vendor": "Fitbit" + }, + "requests": [ + { + "method": "POST", + "url": "https://api.fitbit.com/oauth2/token", + "query": {}, + "headers": { + "Authorization": "Basic Y2xpZW50LWlkLTE6Y2xpZW50LXNlY3JldC0x", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "code": "code-1", + "code_verifier": "pkce-verifier-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/fitbit/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "fitbit", + "method": "exchangeCode", + "duration_ms": 0, + "status": 400, + "error": "http_400" + } + ] + } + ], + "unansweredScript": 0 + }, + "strava_fault": { + "error": { + "name": "FitbitApiError", + "message": "Fitbit exchangeCode error: 400", + "classification": "persistent", + "httpStatus": 400, + "reason": "http_400", + "verb": "exchangeCode", + "vendor": "Fitbit" + }, + "requests": [ + { + "method": "POST", + "url": "https://api.fitbit.com/oauth2/token", + "query": {}, + "headers": { + "Authorization": "Basic Y2xpZW50LWlkLTE6Y2xpZW50LXNlY3JldC0x", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "code": "code-1", + "code_verifier": "pkce-verifier-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/fitbit/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "fitbit", + "method": "exchangeCode", + "duration_ms": 0, + "status": 400, + "error": "http_400" + } + ] + } + ], + "unansweredScript": 0 + }, + "invalid_client": { + "error": { + "name": "FitbitApiError", + "message": "Fitbit exchangeCode error: 401 - invalid_client", + "classification": "reauth_required", + "httpStatus": 401, + "reason": "http_401", + "verb": "exchangeCode", + "upstreamError": "invalid_client", + "vendor": "Fitbit" + }, + "requests": [ + { + "method": "POST", + "url": "https://api.fitbit.com/oauth2/token", + "query": {}, + "headers": { + "Authorization": "Basic Y2xpZW50LWlkLTE6Y2xpZW50LXNlY3JldC0x", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "code": "code-1", + "code_verifier": "pkce-verifier-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/fitbit/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "fitbit", + "method": "exchangeCode", + "duration_ms": 0, + "status": 401, + "error": "http_401" + } + ] + } + ], + "unansweredScript": 0 + }, + "rate_limited": { + "error": { + "name": "FitbitApiError", + "message": "Fitbit exchangeCode error: 429 - rate_limited", + "classification": "transient", + "httpStatus": 429, + "reason": "http_429", + "verb": "exchangeCode", + "upstreamError": "rate_limited", + "vendor": "Fitbit" + }, + "requests": [ + { + "method": "POST", + "url": "https://api.fitbit.com/oauth2/token", + "query": {}, + "headers": { + "Authorization": "Basic Y2xpZW50LWlkLTE6Y2xpZW50LXNlY3JldC0x", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "code": "code-1", + "code_verifier": "pkce-verifier-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/fitbit/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "fitbit", + "method": "exchangeCode", + "duration_ms": 0, + "status": 429, + "error": "http_429" + } + ] + } + ], + "unansweredScript": 0 + }, + "outage": { + "error": { + "name": "FitbitApiError", + "message": "Fitbit exchangeCode error: 503", + "classification": "transient", + "httpStatus": 503, + "reason": "http_503", + "verb": "exchangeCode", + "vendor": "Fitbit" + }, + "requests": [ + { + "method": "POST", + "url": "https://api.fitbit.com/oauth2/token", + "query": {}, + "headers": { + "Authorization": "Basic Y2xpZW50LWlkLTE6Y2xpZW50LXNlY3JldC0x", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "code": "code-1", + "code_verifier": "pkce-verifier-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/fitbit/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "fitbit", + "method": "exchangeCode", + "duration_ms": 0, + "status": 503, + "error": "http_503" + } + ] + } + ], + "unansweredScript": 0 + }, + "fitbit_errors": { + "error": { + "name": "FitbitApiError", + "message": "Fitbit exchangeCode error: 400 - invalid_grant", + "classification": "persistent", + "httpStatus": 400, + "reason": "http_400", + "verb": "exchangeCode", + "upstreamError": "invalid_grant", + "vendor": "Fitbit" + }, + "requests": [ + { + "method": "POST", + "url": "https://api.fitbit.com/oauth2/token", + "query": {}, + "headers": { + "Authorization": "Basic Y2xpZW50LWlkLTE6Y2xpZW50LXNlY3JldC0x", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "client_id": "client-id-1", + "code": "code-1", + "code_verifier": "pkce-verifier-1", + "grant_type": "authorization_code", + "redirect_uri": "https://healthlog.example/api/fitbit/callback" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "fitbit", + "method": "exchangeCode", + "duration_ms": 0, + "status": 400, + "error": "http_400" + } + ] + } + ], + "unansweredScript": 0 + } + }, + "refresh": { + "ok": { + "result": { + "access_token": "access-1", + "refresh_token": "refresh-1", + "expires_in": 3600, + "token_type": "Bearer", + "scope": "granted" + }, + "requests": [ + { + "method": "POST", + "url": "https://api.fitbit.com/oauth2/token", + "query": {}, + "headers": { + "Authorization": "Basic Y2xpZW50LWlkLTE6Y2xpZW50LXNlY3JldC0x", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "fitbit", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 200 + } + ] + } + ], + "unansweredScript": 0 + }, + "empty_body": { + "error": { + "name": "FitbitApiError", + "message": "Fitbit refreshAccessToken error: 200", + "classification": "transient", + "httpStatus": 200, + "reason": "empty_token_body", + "verb": "refreshAccessToken", + "vendor": "Fitbit" + }, + "requests": [ + { + "method": "POST", + "url": "https://api.fitbit.com/oauth2/token", + "query": {}, + "headers": { + "Authorization": "Basic Y2xpZW50LWlkLTE6Y2xpZW50LXNlY3JldC0x", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "fitbit", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 200 + } + ] + } + ], + "unansweredScript": 0 + }, + "invalid_grant": { + "error": { + "name": "FitbitApiError", + "message": "Fitbit refreshAccessToken error: 400 - invalid_grant", + "classification": "persistent", + "httpStatus": 400, + "reason": "http_400", + "verb": "refreshAccessToken", + "upstreamError": "invalid_grant", + "vendor": "Fitbit" + }, + "requests": [ + { + "method": "POST", + "url": "https://api.fitbit.com/oauth2/token", + "query": {}, + "headers": { + "Authorization": "Basic Y2xpZW50LWlkLTE6Y2xpZW50LXNlY3JldC0x", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "fitbit", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 400, + "error": "http_400" + } + ] + } + ], + "unansweredScript": 0 + }, + "strava_fault": { + "error": { + "name": "FitbitApiError", + "message": "Fitbit refreshAccessToken error: 400", + "classification": "persistent", + "httpStatus": 400, + "reason": "http_400", + "verb": "refreshAccessToken", + "vendor": "Fitbit" + }, + "requests": [ + { + "method": "POST", + "url": "https://api.fitbit.com/oauth2/token", + "query": {}, + "headers": { + "Authorization": "Basic Y2xpZW50LWlkLTE6Y2xpZW50LXNlY3JldC0x", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "fitbit", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 400, + "error": "http_400" + } + ] + } + ], + "unansweredScript": 0 + }, + "invalid_client": { + "error": { + "name": "FitbitApiError", + "message": "Fitbit refreshAccessToken error: 401 - invalid_client", + "classification": "reauth_required", + "httpStatus": 401, + "reason": "http_401", + "verb": "refreshAccessToken", + "upstreamError": "invalid_client", + "vendor": "Fitbit" + }, + "requests": [ + { + "method": "POST", + "url": "https://api.fitbit.com/oauth2/token", + "query": {}, + "headers": { + "Authorization": "Basic Y2xpZW50LWlkLTE6Y2xpZW50LXNlY3JldC0x", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "fitbit", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 401, + "error": "http_401" + } + ] + } + ], + "unansweredScript": 0 + }, + "rate_limited": { + "error": { + "name": "FitbitApiError", + "message": "Fitbit refreshAccessToken error: 429 - rate_limited", + "classification": "transient", + "httpStatus": 429, + "reason": "http_429", + "verb": "refreshAccessToken", + "upstreamError": "rate_limited", + "vendor": "Fitbit" + }, + "requests": [ + { + "method": "POST", + "url": "https://api.fitbit.com/oauth2/token", + "query": {}, + "headers": { + "Authorization": "Basic Y2xpZW50LWlkLTE6Y2xpZW50LXNlY3JldC0x", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "fitbit", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 429, + "error": "http_429" + } + ] + } + ], + "unansweredScript": 0 + }, + "outage": { + "error": { + "name": "FitbitApiError", + "message": "Fitbit refreshAccessToken error: 503", + "classification": "transient", + "httpStatus": 503, + "reason": "http_503", + "verb": "refreshAccessToken", + "vendor": "Fitbit" + }, + "requests": [ + { + "method": "POST", + "url": "https://api.fitbit.com/oauth2/token", + "query": {}, + "headers": { + "Authorization": "Basic Y2xpZW50LWlkLTE6Y2xpZW50LXNlY3JldC0x", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "fitbit", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 503, + "error": "http_503" + } + ] + } + ], + "unansweredScript": 0 + }, + "fitbit_errors": { + "error": { + "name": "FitbitApiError", + "message": "Fitbit refreshAccessToken error: 400 - invalid_grant", + "classification": "persistent", + "httpStatus": 400, + "reason": "http_400", + "verb": "refreshAccessToken", + "upstreamError": "invalid_grant", + "vendor": "Fitbit" + }, + "requests": [ + { + "method": "POST", + "url": "https://api.fitbit.com/oauth2/token", + "query": {}, + "headers": { + "Authorization": "Basic Y2xpZW50LWlkLTE6Y2xpZW50LXNlY3JldC0x", + "Content-Type": "application/x-www-form-urlencoded" + }, + "form": { + "grant_type": "refresh_token", + "refresh_token": "refresh-0" + }, + "body": null + } + ], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "fitbit", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 400, + "error": "http_400" + } + ] + } + ], + "unansweredScript": 0 + } + } + } +} diff --git a/src/lib/integrations/oauth-kit/__tests__/disconnect-shared-account.test.ts b/src/lib/integrations/oauth-kit/__tests__/disconnect-shared-account.test.ts index fc3cf7c5a..c0bef7882 100644 --- a/src/lib/integrations/oauth-kit/__tests__/disconnect-shared-account.test.ts +++ b/src/lib/integrations/oauth-kit/__tests__/disconnect-shared-account.test.ts @@ -32,7 +32,9 @@ vi.mock("@/lib/withings/sync", () => ({ WITHINGS_NOTIFY_APPLIS: [1, 44], getWithingsWebhookCallbackUrl: () => "https://app.example/hook", })); -vi.mock("@/lib/strava/client", () => ({ deauthorize: deauthorizeMock })); +vi.mock("@/lib/strava/oauth", () => ({ + stravaOAuth: { deauthorize: deauthorizeMock }, +})); vi.mock("@/lib/strava/credentials", () => ({ STRAVA_DISCONNECTED: { stravaAccessTokenEncrypted: null }, getStravaConnection: vi.fn(async () => ({ accessToken: "at" })), diff --git a/src/lib/integrations/oauth-kit/__tests__/oauth-wire-parity.test.ts b/src/lib/integrations/oauth-kit/__tests__/oauth-wire-parity.test.ts new file mode 100644 index 000000000..fdc5b0fa7 --- /dev/null +++ b/src/lib/integrations/oauth-kit/__tests__/oauth-wire-parity.test.ts @@ -0,0 +1,400 @@ +/** + * What every OAuth provider client puts on the wire, and what it makes of + * the answer, pinned in `__golden__/oauth-wire.json`. + * + * For each provider the authorization URL, the token exchange, the refresh + * and (Strava) the deauthorization are driven against a scripted token + * endpoint at the `safeFetch` seam: a good answer, an empty body, the refusal + * shapes each provider is known to send (an OAuth `invalid_grant`, Strava's + * `Fault`, a Withings status envelope), an unknown client, a rate limit and + * an outage. The golden records every request (method, URL with its query + * as a sorted map, headers, form body as a sorted map), the result or the + * error with its classification, and the external-call line the wide event + * received. + * + * The golden was written from the hand-written clients before they moved + * onto the shared OAuth client factory, so a green run is the proof that the + * factory speaks to each provider exactly as its own client did. Parameter + * order is not recorded: no authorization server reads it. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +interface RecordedRequest { + method: string; + url: string; + query: Record; + headers: Record; + form: Record | null; + body: string | null; +} + +interface Scripted { + status: number; + body: string; + headers?: Record; +} + +const wire = vi.hoisted(() => ({ + requests: [] as RecordedRequest[], + answers: [] as Scripted[], + events: [] as Array<{ method: string; args: unknown[] }>, +})); + +vi.mock("@/lib/safe-fetch", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + safeFetch: async (input: string | URL, init: RequestInit = {}) => { + const url = new URL(String(input)); + const body = typeof init.body === "string" ? init.body : null; + const headers = Object.fromEntries( + Object.entries((init.headers ?? {}) as Record).sort( + ([a], [b]) => a.localeCompare(b), + ), + ); + const isForm = + Object.entries(headers).some( + ([name, value]) => + name.toLowerCase() === "content-type" && + value.includes("x-www-form-urlencoded"), + ) && body !== null; + wire.requests.push({ + method: init.method ?? "GET", + url: `${url.origin}${url.pathname}`, + query: sortedParams(url.searchParams), + headers, + form: isForm ? sortedParams(new URLSearchParams(body)) : null, + body: isForm ? null : body, + }); + const answer = wire.answers.shift() ?? { status: 599, body: "" }; + return new Response(answer.body === "" ? null : answer.body, { + status: answer.status, + headers: answer.headers, + }); + }, + }; +}); + +vi.mock("@/lib/logging/context", async (importOriginal) => { + const actual = await importOriginal(); + const recorder = new Proxy( + {}, + { + get: + (_target, method) => + (...args: unknown[]) => { + wire.events.push({ + method: String(method), + args: args.map((arg) => + arg && typeof arg === "object" && "duration_ms" in arg + ? { ...(arg as Record), duration_ms: 0 } + : arg, + ), + }); + }, + }, + ); + return { ...actual, getEvent: () => recorder }; +}); + +function sortedParams(params: URLSearchParams): Record { + return Object.fromEntries( + [...params.entries()].sort(([a], [b]) => a.localeCompare(b)), + ); +} + +const CREDS = { clientId: "client-id-1", clientSecret: "client-secret-1" }; +const STATE = "state-nonce-1"; +const CHALLENGE = "pkce-challenge-1"; +const VERIFIER = "pkce-verifier-1"; + +const json = (status: number, body: unknown, headers = {}): Scripted => ({ + status, + body: JSON.stringify(body), + headers: { "content-type": "application/json", ...headers }, +}); + +const TOKEN = { + access_token: "access-1", + refresh_token: "refresh-1", + expires_in: 3600, + token_type: "Bearer", + scope: "granted", +}; + +/** The answers every provider's token endpoint is driven through. */ +const STANDARD_ANSWERS: Record = { + ok: [json(200, TOKEN)], + empty_body: [{ status: 200, body: "" }], + invalid_grant: [json(400, { error: "invalid_grant" })], + strava_fault: [ + json(400, { + message: "Bad Request", + errors: [ + { resource: "RefreshToken", field: "refresh_token", code: "invalid" }, + ], + }), + ], + invalid_client: [json(401, { error: "invalid_client" })], + rate_limited: [json(429, { error: "rate_limited" }, { "retry-after": "30" })], + outage: [{ status: 503, body: "unavailable" }], +}; + +/** Withings answers HTTP 200 with a status inside the body. */ +const WITHINGS_ANSWERS: Record = { + ok: [json(200, { status: 0, body: { userid: "4711", ...TOKEN } })], + empty_body: [{ status: 200, body: "" }], + invalid_grant: [json(200, { status: 503, error: "Invalid Params" })], + revoked: [json(200, { status: 100, error: "The hash is missing" })], + invalid_client: [json(200, { status: 401, error: "invalid_client" })], + rate_limited: [json(200, { status: 601, error: "Too Many Requests" })], + outage: [{ status: 503, body: "unavailable" }], +}; + +type Call = () => Promise; + +interface ProviderUnderTest { + answers: Record; + redirectUri: () => string; + authorize: () => string; + exchange: Call; + refresh?: Call; + extra?: Record; +} + +/** + * How each provider's client is called. The calls are the only part of this + * file that follows the clients' API; the golden does not. + */ +async function providers(): Promise> { + const { withingsOAuth } = await import("@/lib/withings/oauth"); + const { whoopOAuth } = await import("@/lib/whoop/oauth"); + const { stravaOAuth } = await import("@/lib/strava/oauth"); + const { ouraOAuth, exchangeOuraCode } = await import("@/lib/oura/oauth"); + const { polarOAuth } = await import("@/lib/polar/oauth"); + const { googleHealthOAuth } = await import("@/lib/google-health/oauth"); + const { fitbitOAuth } = await import("@/lib/fitbit/oauth"); + const MOI = "https://moi.ouraring.com/oauth/v2/ext/oauth-anonymous"; + const pkce = { codeVerifier: VERIFIER }; + return { + withings: { + answers: WITHINGS_ANSWERS, + redirectUri: withingsOAuth.redirectUri, + authorize: () => withingsOAuth.authorizationUrl(STATE, CREDS), + exchange: () => withingsOAuth.exchange("code-1", CREDS), + refresh: () => withingsOAuth.refresh("refresh-0", CREDS), + }, + whoop: { + answers: STANDARD_ANSWERS, + redirectUri: whoopOAuth.redirectUri, + authorize: () => whoopOAuth.authorizationUrl(STATE, CREDS), + exchange: () => whoopOAuth.exchange("code-1", CREDS), + refresh: () => whoopOAuth.refresh("refresh-0", CREDS), + }, + strava: { + answers: STANDARD_ANSWERS, + redirectUri: stravaOAuth.redirectUri, + authorize: () => stravaOAuth.authorizationUrl(STATE, CREDS), + exchange: () => stravaOAuth.exchange("code-1", CREDS), + refresh: () => stravaOAuth.refresh("refresh-0", CREDS), + extra: { + deauthorize_ok: { + answers: [json(200, { access_token: "access-1" })], + call: () => stravaOAuth.deauthorize("access-1"), + }, + deauthorize_refused: { + answers: [json(401, { message: "Authorization Error" })], + call: () => stravaOAuth.deauthorize("access-1"), + }, + }, + }, + oura: { + answers: STANDARD_ANSWERS, + redirectUri: ouraOAuth.redirectUri, + authorize: () => ouraOAuth.authorizationUrl(STATE, CREDS), + exchange: () => exchangeOuraCode("code-1", CREDS), + refresh: () => ouraOAuth.refresh("refresh-0", CREDS), + extra: { + exchange_from_moi_refused: { + answers: [json(400, { error: "invalid_grant" })], + call: () => exchangeOuraCode("code-1", CREDS, MOI), + }, + exchange_legacy_fallback: { + answers: [json(400, { error: "invalid_request" }), json(200, TOKEN)], + call: () => exchangeOuraCode("code-1", CREDS, null), + }, + exchange_both_refused: { + answers: [ + json(401, { error: "invalid_client" }), + json(400, { error: "invalid_grant" }), + ], + call: () => exchangeOuraCode("code-1", CREDS), + }, + refresh_legacy_fallback: { + answers: [json(404, { error: "not_found" }), json(200, TOKEN)], + call: () => ouraOAuth.refresh("refresh-0", CREDS), + }, + refresh_outage_no_fallback: { + answers: [{ status: 502, body: "" }], + call: () => ouraOAuth.refresh("refresh-0", CREDS), + }, + }, + }, + polar: { + answers: STANDARD_ANSWERS, + redirectUri: polarOAuth.redirectUri, + authorize: () => polarOAuth.authorizationUrl(STATE, CREDS), + exchange: () => polarOAuth.exchange("code-1", CREDS), + }, + "google-health": { + answers: STANDARD_ANSWERS, + redirectUri: googleHealthOAuth.redirectUri, + authorize: () => + googleHealthOAuth.authorizationUrl(STATE, CREDS, CHALLENGE), + exchange: () => googleHealthOAuth.exchange("code-1", CREDS, pkce), + refresh: () => googleHealthOAuth.refresh("refresh-0", CREDS), + }, + fitbit: { + answers: { + ...STANDARD_ANSWERS, + fitbit_errors: [ + json(400, { + errors: [ + { errorType: "invalid_grant", message: "Refresh token invalid" }, + ], + success: false, + }), + ], + }, + redirectUri: fitbitOAuth.redirectUri, + authorize: () => fitbitOAuth.authorizationUrl(STATE, CREDS, CHALLENGE), + exchange: () => fitbitOAuth.exchange("code-1", CREDS, pkce), + refresh: () => fitbitOAuth.refresh("refresh-0", CREDS), + }, + }; +} + +function describeError(err: unknown): Record { + if (!(err instanceof Error)) return { thrown: String(err) }; + const fields: Record = { + name: err.name, + message: err.message, + }; + for (const key of [ + "classification", + "httpStatus", + "withingsStatus", + "reason", + "verb", + "upstreamError", + "vendor", + "retryAfterSeconds", + ]) { + const value = (err as unknown as Record)[key]; + if (value !== undefined) fields[key] = value; + } + return fields; +} + +async function drive(answers: Scripted[], call: Call) { + wire.requests.length = 0; + wire.events.length = 0; + wire.answers = answers.map((a) => ({ ...a })); + let outcome: Record; + try { + outcome = { result: (await call()) ?? null }; + } catch (err) { + outcome = { error: describeError(err) }; + } + return { + ...outcome, + requests: [...wire.requests], + events: [...wire.events], + unansweredScript: wire.answers.length, + }; +} + +beforeEach(() => { + vi.stubEnv("NEXT_PUBLIC_APP_URL", "https://healthlog.example"); + for (const name of [ + "WITHINGS_REDIRECT_URI", + "WHOOP_REDIRECT_URI", + "STRAVA_REDIRECT_URI", + "OURA_REDIRECT_URI", + "POLAR_REDIRECT_URI", + "GOOGLE_HEALTH_REDIRECT_URI", + "FITBIT_REDIRECT_URI", + ]) { + vi.stubEnv(name, ""); + } +}); + +describe("OAuth wire parity", () => { + it("speaks to every provider exactly as the golden records", async () => { + const transcript: Record = {}; + for (const [name, provider] of Object.entries(await providers())) { + const authorizeUrl = new URL(provider.authorize()); + const entry: Record = { + redirectUri: provider.redirectUri(), + authorize: { + url: `${authorizeUrl.origin}${authorizeUrl.pathname}`, + query: sortedParams(authorizeUrl.searchParams), + }, + }; + const calls: Array<[string, Call | undefined]> = [ + ["exchange", provider.exchange], + ["refresh", provider.refresh], + ]; + for (const [verb, call] of calls) { + if (!call) continue; + const outcomes: Record = {}; + for (const [scenario, answers] of Object.entries(provider.answers)) { + outcomes[scenario] = await drive(answers, call); + } + entry[verb] = outcomes; + } + for (const [scenario, extra] of Object.entries(provider.extra ?? {})) { + entry[scenario] = await drive(extra.answers, extra.call); + } + transcript[name] = entry; + } + + // The run must have reached every provider's token endpoint; an empty + // transcript would match an empty golden. + expect(Object.keys(transcript)).toHaveLength(7); + + await expect( + JSON.stringify(transcript, null, 2) + "\n", + ).toMatchFileSnapshot("./__golden__/oauth-wire.json"); + }); + + it("honours an explicit redirect URI from the environment", async () => { + vi.stubEnv( + "WHOOP_REDIRECT_URI", + "https://elsewhere.example/api/whoop/callback", + ); + vi.stubEnv( + "GOOGLE_HEALTH_REDIRECT_URI", + "https://healthlog.example/api/google-health/callback", + ); + const all = await providers(); + expect(all.whoop!.redirectUri()).toBe( + "https://elsewhere.example/api/whoop/callback", + ); + expect(all["google-health"]!.redirectUri()).toBe( + "https://healthlog.example/api/google-health/callback", + ); + vi.stubEnv( + "GOOGLE_HEALTH_REDIRECT_URI", + "https://elsewhere.example/api/google-health/callback", + ); + expect(() => all["google-health"]!.redirectUri()).toThrow( + /does not match NEXT_PUBLIC_APP_URL origin/, + ); + vi.stubEnv( + "FITBIT_REDIRECT_URI", + "http://healthlog.example/api/fitbit/callback", + ); + expect(() => all.fitbit!.redirectUri()).toThrow(/must be https/); + }); +}); diff --git a/src/lib/integrations/oauth-kit/client.ts b/src/lib/integrations/oauth-kit/client.ts new file mode 100644 index 000000000..db71f9937 --- /dev/null +++ b/src/lib/integrations/oauth-kit/client.ts @@ -0,0 +1,348 @@ +/** + * The one OAuth client every provider integration speaks through: the + * authorization URL, the code exchange, the refresh, and (Strava) the + * deauthorization, with the token endpoint's answer classified the same way + * for every provider. + * + * A provider declares what differs (`OAuthClientConfig`): its endpoints and + * scope, how its client authenticates (credentials in the form, or HTTP Basic + * with or without the client id beside it), the fields its grants add, how + * it classifies an answer and names the upstream refusal, the error type it + * throws, and where the token sits in the body. Everything else is here once: + * the form post, the external-call line on the wide event, the refusal + * becoming the provider's own classified error, and a 2xx without a token + * object (a captive portal, a gateway 200, a 204) becoming a transient + * `empty_token_body` failure instead of a null the caller dereferences. + * + * The token lifecycle (when to refresh, under which lock, writing the grant + * back) is `token.ts`; this module is the wire. + * + * `__tests__/oauth-wire-parity.test.ts` pins what every provider puts on the + * wire and makes of each answer against the hand-written clients this + * replaced. + */ +import { getEvent } from "@/lib/logging/context"; +import { safeFetch } from "@/lib/safe-fetch"; +import { + classifyHttpStatus, + type IntegrationClassification, +} from "@/lib/integrations/http-status-classifier"; + +export interface OAuthClientCredentials { + clientId: string; + clientSecret: string; +} + +/** A provider's verdict on one token-endpoint answer. */ +export interface TokenVerdict { + classification: IntegrationClassification; + /** The status that drove it: HTTP, or a provider's in-body status. */ + status: number | undefined; + reason: string; +} + +/** What the provider's error type is built from. */ +export interface TokenFailure extends TokenVerdict { + verb: string; + upstreamError: string | undefined; +} + +type FormFields = Readonly>; + +export interface OAuthClientConfig { + /** The service name on the wide event's external-call line. */ + service: string; + authorizeUrl: string; + tokenUrl: string; + /** + * A second token endpoint asked after the first refused (Oura's legacy + * one). Never asked first: an authorization code works once. + */ + legacyTokenUrl?: { + url: string; + /** HTTP statuses of the first answer that send the request on. */ + retryOn: ReadonlySet; + }; + redirectUri: () => string; + /** Query parameters of the authorization URL after the client and redirect. */ + authorizeParams: () => FormFields; + /** S256 PKCE: the authorization URL carries the challenge, the exchange the verifier. */ + pkce?: boolean; + /** + * How the client authenticates at the token endpoint: its id and secret in + * the form, or HTTP Basic, with the id also in the form for the grants + * named. + */ + clientAuth: + | { kind: "form" } + | { kind: "basic"; idInForm: { exchange: boolean; refresh: boolean } }; + /** Send `Accept: application/json` with the token request. */ + acceptJson?: boolean; + /** Whether the exchange repeats the redirect URI (RFC 6749 4.1.3). Default true. */ + exchangeRedirectUri?: boolean; + /** Fields a grant adds to the form (Withings' `action`, WHOOP's `scope`). */ + extraFields?: { exchange?: FormFields; refresh?: FormFields }; + /** Whether the provider issues refresh tokens at all. */ + refresh: boolean; + /** Classify one answer. Default: the HTTP status alone. */ + classify?: (httpStatus: number, body: unknown) => TokenVerdict; + /** The provider's own name for a refusal, read from the body. */ + upstreamError?: (body: unknown) => string | undefined; + /** The verb a failure is filed under, when it differs from the call's. */ + failureVerb?: { exchange?: string; refresh?: string }; + /** Build the provider's error type. */ + error: (failure: TokenFailure) => Error; + /** The token object inside the answer. Default: the body itself. */ + tokenOf?: (body: unknown) => unknown; + /** Best-effort deauthorization at the provider (Strava). */ + deauthorizeUrl?: string; +} + +export interface OAuthClient { + readonly redirectUri: () => string; + /** The browser redirect to the consent screen; `state` is the CSRF nonce. */ + authorizationUrl( + state: string, + credentials: OAuthClientCredentials, + codeChallenge?: string, + ): string; + /** + * Exchange an authorization code. `codeVerifier` is the PKCE verifier; + * `useLegacyEndpoint` is false when the code is known to come from the + * current authorization server, so a refusal is final. + */ + exchange( + code: string, + credentials: OAuthClientCredentials, + options?: { codeVerifier?: string; useLegacyEndpoint?: boolean }, + ): Promise; + /** Refresh an access token. Throws for a provider without refresh tokens. */ + refresh( + refreshToken: string, + credentials: OAuthClientCredentials, + ): Promise; + /** Tell the provider the grant is gone. Never throws on a refusal. */ + deauthorize(accessToken: string): Promise; +} + +const EXCHANGE_VERB = "exchangeCode"; +const REFRESH_VERB = "refreshAccessToken"; + +/** HTTP Basic credentials for the confidential client (RFC 6749 2.3.1). */ +export function basicAuthorization( + credentials: OAuthClientCredentials, +): string { + const raw = `${credentials.clientId}:${credentials.clientSecret}`; + return `Basic ${Buffer.from(raw, "utf8").toString("base64")}`; +} + +/** The RFC 6749 `error` code of a refusal body, when it has one. */ +export function oauthErrorCode(body: unknown): string | undefined { + if (body === null || typeof body !== "object") return undefined; + const error = (body as { error?: unknown }).error; + return typeof error === "string" ? error : undefined; +} + +function defaultVerdict(httpStatus: number): TokenVerdict { + const verdict = classifyHttpStatus(httpStatus); + return { + classification: verdict.classification, + status: verdict.httpStatus, + reason: verdict.reason, + }; +} + +interface Answer { + httpStatus: number; + body: unknown; + verdict: TokenVerdict; +} + +export function defineOAuthClient( + config: OAuthClientConfig, +): OAuthClient { + const classify = + config.classify ?? ((httpStatus: number) => defaultVerdict(httpStatus)); + const upstreamError = config.upstreamError ?? oauthErrorCode; + const tokenOf = config.tokenOf ?? ((body: unknown) => body); + + async function post( + url: string, + form: Record, + credentials: OAuthClientCredentials, + eventMethod: string, + ): Promise { + const headers: Record = { + "Content-Type": "application/x-www-form-urlencoded", + }; + if (config.acceptJson) headers.Accept = "application/json"; + if (config.clientAuth.kind === "basic") { + headers.Authorization = basicAuthorization(credentials); + } + const start = performance.now(); + const res = await safeFetch(url, { + method: "POST", + headers, + body: new URLSearchParams(form).toString(), + }); + const body: unknown = await res.json().catch(() => null); + const verdict = classify(res.status, body); + getEvent()?.addExternalCall({ + service: config.service, + method: eventMethod, + duration_ms: Math.round(performance.now() - start), + status: res.status, + error: verdict.classification === "success" ? undefined : verdict.reason, + }); + return { httpStatus: res.status, body, verdict }; + } + + function settle(answer: Answer, verb: string): Token { + const { verdict, body } = answer; + if (verdict.classification !== "success") { + throw config.error({ + ...verdict, + verb, + upstreamError: upstreamError(body), + }); + } + const token = tokenOf(body); + if (token === null || typeof token !== "object") { + throw config.error({ + classification: "transient", + status: answer.httpStatus, + reason: "empty_token_body", + verb, + upstreamError: undefined, + }); + } + return token as Token; + } + + async function request( + grant: "exchange" | "refresh", + form: Record, + credentials: OAuthClientCredentials, + mayUseLegacy: boolean, + ): Promise { + const eventMethod = grant === "exchange" ? EXCHANGE_VERB : REFRESH_VERB; + const verb = config.failureVerb?.[grant] ?? eventMethod; + const first = await post(config.tokenUrl, form, credentials, eventMethod); + const legacy = config.legacyTokenUrl; + if (legacy && mayUseLegacy && legacy.retryOn.has(first.httpStatus)) { + const second = await post( + legacy.url, + form, + credentials, + `${eventMethod}.legacy`, + ); + if (second.httpStatus >= 200 && second.httpStatus < 300) { + return settle(second, verb); + } + // Neither endpoint took it; the first answer describes the grant for + // every current client, so that is the one reported. + } + return settle(first, verb); + } + + function clientFields( + grant: "exchange" | "refresh", + credentials: OAuthClientCredentials, + ): Record { + if (config.clientAuth.kind === "form") { + return { + client_id: credentials.clientId, + client_secret: credentials.clientSecret, + }; + } + return config.clientAuth.idInForm[grant] + ? { client_id: credentials.clientId } + : {}; + } + + return { + redirectUri: config.redirectUri, + + authorizationUrl(state, credentials, codeChallenge) { + if (config.pkce && !codeChallenge) { + throw new Error( + `${config.service}: the authorization needs a PKCE challenge`, + ); + } + const params = new URLSearchParams({ + response_type: "code", + client_id: credentials.clientId, + redirect_uri: config.redirectUri(), + ...config.authorizeParams(), + ...(config.pkce && codeChallenge + ? { code_challenge: codeChallenge, code_challenge_method: "S256" } + : {}), + state, + }); + return `${config.authorizeUrl}?${params}`; + }, + + exchange(code, credentials, options = {}) { + if (config.pkce && !options.codeVerifier) { + throw new Error( + `${config.service}: the exchange needs the PKCE verifier`, + ); + } + return request( + "exchange", + { + ...config.extraFields?.exchange, + grant_type: "authorization_code", + code, + ...clientFields("exchange", credentials), + ...(config.exchangeRedirectUri === false + ? {} + : { redirect_uri: config.redirectUri() }), + ...(options.codeVerifier + ? { code_verifier: options.codeVerifier } + : {}), + }, + credentials, + options.useLegacyEndpoint ?? true, + ); + }, + + refresh(refreshToken, credentials) { + if (!config.refresh) { + return Promise.reject( + new Error(`${config.service} issues no refresh tokens`), + ); + } + return request( + "refresh", + { + ...config.extraFields?.refresh, + grant_type: "refresh_token", + refresh_token: refreshToken, + ...clientFields("refresh", credentials), + }, + credentials, + true, + ); + }, + + async deauthorize(accessToken) { + if (!config.deauthorizeUrl) return; + const start = performance.now(); + const res = await safeFetch(config.deauthorizeUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + }, + }); + getEvent()?.addExternalCall({ + service: config.service, + method: "deauthorize", + duration_ms: Math.round(performance.now() - start), + status: res.status, + error: res.ok ? undefined : `http_${res.status}`, + }); + }, + }; +} diff --git a/src/lib/integrations/oauth-kit/connect-start.ts b/src/lib/integrations/oauth-kit/connect-start.ts index 068c9e764..a03f9eec5 100644 --- a/src/lib/integrations/oauth-kit/connect-start.ts +++ b/src/lib/integrations/oauth-kit/connect-start.ts @@ -20,35 +20,33 @@ import { mintSignedState, oauthStateCookieName, } from "@/lib/oauth/signed-state"; -import { getAuthorizationUrl as withingsAuthorizationUrl } from "@/lib/withings/client"; +import { withingsOAuth } from "@/lib/withings/oauth"; import { getUserWithingsCredentials } from "@/lib/withings/credentials"; import { WITHINGS_OAUTH_STATE_COOKIE, WITHINGS_OAUTH_STATE_TTL_MS, mintWithingsOAuthStateNonce, } from "@/lib/withings/oauth-state"; -import { getAuthorizationUrl as whoopAuthorizationUrl } from "@/lib/whoop/client"; +import { whoopOAuth } from "@/lib/whoop/oauth"; import { getUserWhoopCredentials } from "@/lib/whoop/credentials"; import { WHOOP_OAUTH_STATE_COOKIE, WHOOP_OAUTH_STATE_TTL_MS, mintWhoopOAuthStateNonce, } from "@/lib/whoop/oauth-state"; -import { - generatePkcePair, - getAuthorizationUrl as googleHealthAuthorizationUrl, -} from "@/lib/google-health/client"; +import { googleHealthOAuth } from "@/lib/google-health/oauth"; +import { generatePkcePair } from "./pkce"; import { getUserGoogleHealthCredentials } from "@/lib/google-health/credentials"; import { GOOGLE_HEALTH_OAUTH_STATE_COOKIE, GOOGLE_HEALTH_OAUTH_STATE_TTL_MS, mintGoogleHealthOAuthStateNonce, } from "@/lib/google-health/oauth-state"; -import { getAuthorizationUrl as ouraAuthorizationUrl } from "@/lib/oura/client"; +import { ouraOAuth } from "@/lib/oura/oauth"; import { getOuraClientCredentials } from "@/lib/oura/credentials"; -import { getAuthorizationUrl as polarAuthorizationUrl } from "@/lib/polar/client"; +import { polarOAuth } from "@/lib/polar/oauth"; import { getPolarClientCredentials } from "@/lib/polar/credentials"; -import { getAuthorizationUrl as stravaAuthorizationUrl } from "@/lib/strava/client"; +import { stravaOAuth } from "@/lib/strava/oauth"; import { getStravaClientCredentials } from "@/lib/strava/credentials"; import type { OAuthKitProvider } from "./token"; @@ -152,7 +150,7 @@ export async function startOAuthConnect( value: nonce, maxAgeSeconds: Math.floor(WITHINGS_OAUTH_STATE_TTL_MS / 1000), }, - () => withingsAuthorizationUrl(nonce, creds), + () => withingsOAuth.authorizationUrl(nonce, creds), ); } case "whoop": { @@ -174,7 +172,7 @@ export async function startOAuthConnect( value: nonce, maxAgeSeconds: Math.floor(WHOOP_OAUTH_STATE_TTL_MS / 1000), }, - () => whoopAuthorizationUrl(nonce, creds), + () => whoopOAuth.authorizationUrl(nonce, creds), ); } case "google-health": { @@ -199,26 +197,28 @@ export async function startOAuthConnect( value: nonce, maxAgeSeconds: Math.floor(GOOGLE_HEALTH_OAUTH_STATE_TTL_MS / 1000), }, - () => googleHealthAuthorizationUrl(nonce, creds, challenge), + () => googleHealthOAuth.authorizationUrl(nonce, creds, challenge), ); } case "oura": { const creds = await getOuraClientCredentials(userId); if (!creds) return { ok: false, reason: "nocreds" }; - return signedStart("oura", userId, (s) => ouraAuthorizationUrl(s, creds)); + return signedStart("oura", userId, (s) => + ouraOAuth.authorizationUrl(s, creds), + ); } case "polar": { const creds = await getPolarClientCredentials(userId); if (!creds) return { ok: false, reason: "nocreds" }; return signedStart("polar", userId, (s) => - polarAuthorizationUrl(s, creds), + polarOAuth.authorizationUrl(s, creds), ); } case "strava": { const creds = await getStravaClientCredentials(userId); if (!creds) return { ok: false, reason: "nocreds" }; return signedStart("strava", userId, (s) => - stravaAuthorizationUrl(s, creds), + stravaOAuth.authorizationUrl(s, creds), ); } } diff --git a/src/lib/integrations/oauth-kit/disconnect.ts b/src/lib/integrations/oauth-kit/disconnect.ts index 03f8b8e90..1d71453c8 100644 --- a/src/lib/integrations/oauth-kit/disconnect.ts +++ b/src/lib/integrations/oauth-kit/disconnect.ts @@ -20,7 +20,7 @@ import { WITHINGS_NOTIFY_APPLIS, getWithingsWebhookCallbackUrl, } from "@/lib/withings/sync"; -import { deauthorize } from "@/lib/strava/client"; +import { stravaOAuth } from "@/lib/strava/oauth"; import { STRAVA_DISCONNECTED, getStravaConnection, @@ -144,7 +144,7 @@ async function dropGrant( : []; if (sharedWith.length === 0) { try { - await deauthorize(connection.accessToken); + await stravaOAuth.deauthorize(connection.accessToken); } catch (err) { getEvent()?.addWarning( `strava: deauthorize failed on disconnect: ${err}`, diff --git a/src/lib/integrations/oauth-kit/pkce.ts b/src/lib/integrations/oauth-kit/pkce.ts new file mode 100644 index 000000000..0b21ea73a --- /dev/null +++ b/src/lib/integrations/oauth-kit/pkce.ts @@ -0,0 +1,20 @@ +/** + * PKCE (RFC 7636, S256) for the providers that take it. + * + * The verifier is stashed on the OAuth-state row at connect and presented on + * the token exchange at callback, never in a cookie or a URL. 64 random bytes + * give an 86-character base64url verifier, inside the 43 to 128 the RFC + * allows and well past its 256-bit entropy floor. + */ +import { createHash, randomBytes } from "node:crypto"; + +export interface PkcePair { + verifier: string; + challenge: string; +} + +export function generatePkcePair(): PkcePair { + const verifier = randomBytes(64).toString("base64url"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} diff --git a/src/lib/integrations/oauth-kit/redirect-uri.ts b/src/lib/integrations/oauth-kit/redirect-uri.ts new file mode 100644 index 000000000..c00b6ba93 --- /dev/null +++ b/src/lib/integrations/oauth-kit/redirect-uri.ts @@ -0,0 +1,88 @@ +/** + * The OAuth `redirect_uri` of a provider integration. + * + * An explicit `_REDIRECT_URI` wins; otherwise the callback route + * under `NEXT_PUBLIC_APP_URL`. An empty or blank value counts as unset: the + * compose whitelist materialises an unset variable as an empty string, which + * must fall through to the derived URI rather than go out as `redirect_uri=`. + */ +import { envOr, envValue } from "@/lib/env"; + +/** The configured or derived redirect URI, unchecked. */ +export function derivedRedirectUri( + envVar: string, + callbackPath: string, +): string { + return envOr(envVar, `${envValue("NEXT_PUBLIC_APP_URL")}${callbackPath}`); +} + +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]); + +/** + * The redirect URI, checked against an allowlist before it is used + * (defence in depth for the providers that need it). + * + * The value is operator configuration, not user input, and the provider's + * registered-redirect check is the real backstop. But a misconfigured or + * `Host`-coerced `NEXT_PUBLIC_APP_URL` (a reverse proxy reflecting a forwarded + * Host) would otherwise send the authorization code's landing URL off-origin, + * so the handshake fails fast instead: + * - the URI must be absolute and parseable, + * - https, or http on a loopback development host, + * - on the fixed callback path, + * - and an explicit URI must share its origin with `NEXT_PUBLIC_APP_URL`. + */ +export function checkedRedirectUri( + label: string, + envVar: string, + callbackPath: string, +): string { + const explicit = envValue(envVar); + const appUrl = envValue("NEXT_PUBLIC_APP_URL"); + const raw = explicit ?? (appUrl ? `${appUrl}${callbackPath}` : undefined); + + if (!raw) { + throw new Error( + `${label} redirect_uri is not configured — set ${envVar} or NEXT_PUBLIC_APP_URL`, + ); + } + + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + throw new Error(`${label} redirect_uri is not an absolute URL: ${raw}`); + } + + const isLoopback = LOOPBACK_HOSTS.has(parsed.hostname); + if ( + parsed.protocol !== "https:" && + !(parsed.protocol === "http:" && isLoopback) + ) { + throw new Error( + `${label} redirect_uri must be https (or http on localhost): ${parsed.origin}`, + ); + } + + if (parsed.pathname !== callbackPath) { + throw new Error( + `${label} redirect_uri must target ${callbackPath}, got ${parsed.pathname}`, + ); + } + + if (explicit && appUrl) { + let appOrigin: string; + try { + appOrigin = new URL(appUrl).origin; + } catch { + throw new Error(`NEXT_PUBLIC_APP_URL is not an absolute URL: ${appUrl}`); + } + if (parsed.origin !== appOrigin) { + throw new Error( + `${label} redirect_uri origin ${parsed.origin} does not match NEXT_PUBLIC_APP_URL origin ${appOrigin}`, + ); + } + } + + return parsed.toString(); +} diff --git a/src/lib/integrations/sync-kit/after-write.ts b/src/lib/integrations/sync-kit/after-write.ts new file mode 100644 index 000000000..99c0b542c --- /dev/null +++ b/src/lib/integrations/sync-kit/after-write.ts @@ -0,0 +1,140 @@ +/** + * The one step after a provider sync or import writes measurements. + * + * Every provider used to end its write with its own hand-written tail: fold + * the touched rollup days, drop the status cards of the touched types, mark + * the read caches stale, emit the data arrivals. The copies drifted (one + * skipped the status cards, one marked every cached block stale, one never + * marked anything), and each imported the whole insight generator to reach + * the invalidation it needed. `afterProviderWrite` is that tail, in one fixed + * order: + * + * 1. the freshly inserted rows go out on the data-arrival spine + * (fire and forget); + * 2. the touched `(type, day)` buckets are refolded, the read caches are + * marked stale back to the earliest touched reading (older closed blocks + * survive), and the status cards of the touched types are re-warmed + * (`afterMeasurementMutation`, shared with the interactive routes); + * 3. where the provider asks for it, the dashboard snapshot is evicted. + * + * Every leg is best effort: a populator hiccup never fails the sync. The + * result says whether the fold failed, for the providers whose cycle verdict + * counts it. + * + * A full-history backfill defers the per-day fold and refolds the whole + * touched range once at the end (`afterProviderBackfill`). + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { + emitInsertedMeasurementArrivals, + type InsertedMeasurementArrivalRow, +} from "@/lib/arrivals/measurement-emit"; +import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate"; +import { invalidateStatusInsightsForTypes } from "@/lib/insights/status-invalidation"; +import { getEvent } from "@/lib/logging/context"; +import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; +import { + collapseToTypeDayKeys, + recomputeUserRollups, +} from "@/lib/rollups/measurement-rollups"; + +type ArrivalSource = Parameters[2]; + +export interface TouchedReading { + type: MeasurementType; + measuredAt: Date; +} + +export interface ProviderWrite { + userId: string; + /** The provider, as the warn lines name it (`withings`, `google-health`). */ + provider: string; + /** Every reading the write created, changed or displaced. */ + touched: readonly TouchedReading[]; + /** Rows freshly inserted, for the arrival spine; omitted when none go out. */ + arrivals?: { + rows: InsertedMeasurementArrivalRow[]; + source: ArrivalSource; + }; + /** + * Re-warm the touched status cards now. A provider that writes in chunks + * and invalidates once at the end passes false and calls + * `invalidateProviderStatus` itself. + */ + invalidateStatus?: boolean; + /** Evict the dashboard snapshot as well (a sync with no client mutation). */ + evictDashboard?: boolean; +} + +export async function afterProviderWrite( + write: ProviderWrite, +): Promise<{ rollupFailed: boolean }> { + const { userId, provider, touched } = write; + if (write.arrivals) { + void emitInsertedMeasurementArrivals( + userId, + write.arrivals.rows, + write.arrivals.source, + ).catch(() => {}); + } + const { rollupFailed } = await afterMeasurementMutation( + userId, + [...touched], + { + source: "sync", + label: provider, + invalidateStatus: write.invalidateStatus, + }, + ); + if (rollupFailed) { + getEvent()?.addWarning( + `${provider}: rollup recompute failed for ${userId}`, + ); + } + if (write.evictDashboard && touched.length > 0) { + invalidateUserDashboardSnapshot(userId); + } + return { rollupFailed }; +} + +/** Re-warm the status cards of `types` after a sync (fire and forget). */ +export function invalidateProviderStatus( + userId: string, + provider: string, + types: Iterable, +): void { + const list = [...new Set(types)]; + if (list.length === 0) return; + void invalidateStatusInsightsForTypes(userId, list, { + source: "sync", + }).catch((err) => { + getEvent()?.addWarning( + `${provider}: status-insight invalidate failed for ${userId}: ${err}`, + ); + }); +} + +/** + * The end of a full-history backfill: refold the whole touched range once + * (from the first touched day to the end of the last), then re-warm the + * touched status cards. Throws when the fold fails, for the caller to count. + */ +export async function afterProviderBackfill(input: { + userId: string; + provider: string; + /** The deferred `(type, day)` keys the backfill's writes collected. */ + keys: readonly TouchedReading[]; +}): Promise { + const days = collapseToTypeDayKeys([...input.keys]); + if (days.length === 0) return; + const types = Array.from(new Set(days.map((key) => key.type))); + const sorted = days + .map((key) => key.measuredAt.getTime()) + .sort((a, b) => a - b); + const from = new Date(sorted[0]!); + // The keys are UTC day starts; the end is past the last touched day so the + // aggregator's `< to` bound covers it. + const to = new Date(sorted[sorted.length - 1]! + 24 * 60 * 60 * 1000); + await recomputeUserRollups(input.userId, { types, from, to }); + invalidateProviderStatus(input.userId, input.provider, types); +} diff --git a/src/lib/jobs/__tests__/apple-health-import-legacy-bridge.test.ts b/src/lib/jobs/__tests__/apple-health-import-legacy-bridge.test.ts index 1b50253ee..30191031c 100644 --- a/src/lib/jobs/__tests__/apple-health-import-legacy-bridge.test.ts +++ b/src/lib/jobs/__tests__/apple-health-import-legacy-bridge.test.ts @@ -25,10 +25,10 @@ vi.mock("@/lib/jobs/boss-instance", () => ({ import { APPLE_HEALTH_IMPORT_PARSER_REVISION, APPLE_HEALTH_IMPORT_SEND_OPTIONS, - APPLE_HEALTH_IMPORT_V2_QUEUE, migrateLegacyAppleHealthImport, type AppleHealthImportPayload, } from "../apple-health-import-worker"; +import { APPLE_HEALTH_IMPORT_V2_QUEUE } from "@/lib/jobs/apple-health-import-queue"; const payload: AppleHealthImportPayload = { userId: "user-1", diff --git a/src/lib/jobs/__tests__/apple-health-import-reconcile.test.ts b/src/lib/jobs/__tests__/apple-health-import-reconcile.test.ts index 6cfaf5e4c..270fe9565 100644 --- a/src/lib/jobs/__tests__/apple-health-import-reconcile.test.ts +++ b/src/lib/jobs/__tests__/apple-health-import-reconcile.test.ts @@ -1,5 +1,5 @@ /** - * v1.32.1 (issue #588) — periodic orphan-`ImportJob` reconcile. + * Periodic orphan-`ImportJob` reconcile (issue #588). * * Before this change `reconcileOrphanImportJobs()` only ran once, at * worker boot. A worker that crashed mid-extraction (OOM, restart) and @@ -11,10 +11,9 @@ * that one boot-time pass. These tests cover both the periodic-tick * wiring (queue provisioned, cron scheduled, handler bound) and the * reconcile logic itself (a genuinely-abandoned row flips to `failed`; - * a row another worker is still actively running does not). + * a row another worker is still actively running does not). The wiring is + * read off the worker registry. */ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ @@ -45,19 +44,22 @@ vi.mock("@/lib/jobs/boss-instance", () => ({ import { APPLE_HEALTH_IMPORT_PARSER_REVISION, - APPLE_HEALTH_IMPORT_V2_QUEUE, - IMPORT_JOB_RECONCILE_CRON, - IMPORT_JOB_RECONCILE_QUEUE, handleImportJobReconcileTick, reconcileOrphanImportJobs, stagedImportFilesInUse, } from "../apple-health-import-worker"; +import { + APPLE_HEALTH_IMPORT_V2_QUEUE, + IMPORT_JOB_RECONCILE_CRON, + IMPORT_JOB_RECONCILE_QUEUE, +} from "@/lib/jobs/apple-health-import-queue"; import { sweepStaleImportStaging } from "@/lib/import/apple-health-staging"; - -const maintenanceSource = readFileSync( - join(process.cwd(), "src/lib/jobs/reminder/register-maintenance.ts"), - "utf8", -); +import { cronIsTheRetry } from "@/lib/jobs/reminder/registrar-shared"; +import { + groupOf, + jobOf, + loadedHandler, +} from "@/lib/jobs/registry/__tests__/registry-helpers"; beforeEach(() => { vi.resetAllMocks(); @@ -66,30 +68,21 @@ beforeEach(() => { }); describe("periodic reconcile — wiring", () => { - it("provisions the queue, schedules a 15-minute cron, and binds the handler", () => { + it("provisions the queue, schedules a 15-minute cron, and binds the handler", async () => { expect(IMPORT_JOB_RECONCILE_QUEUE).toBe("apple-health-import-reconcile"); expect(IMPORT_JOB_RECONCILE_CRON).toBe("*/15 * * * *"); - const allQueues = maintenanceSource.match( - /const allQueues\s*=\s*\[([\s\S]*?)\];/, - ); - expect(allQueues).not.toBeNull(); - expect(allQueues![1]).toMatch(/\bIMPORT_JOB_RECONCILE_QUEUE\b/); - - const schedules = maintenanceSource.match( - /const schedules[\s\S]*?=\s*\[([\s\S]*?)\];/, - ); - expect(schedules).not.toBeNull(); - // The third element pins the retry policy. It is load-bearing now that + expect(groupOf(IMPORT_JOB_RECONCILE_QUEUE).name).toBe("maintenance"); + const job = jobOf(IMPORT_JOB_RECONCILE_QUEUE); + expect(job.cron).toBe(IMPORT_JOB_RECONCILE_CRON); + // The schedule options pin the retry policy. It is load-bearing now that // the handler reports a failed outcome instead of swallowing: without // `cronIsTheRetry` a database outage would run the same doomed sweep // three times per tick, and the next tick is already the retry. - expect(schedules![1]).toMatch( - /\[IMPORT_JOB_RECONCILE_QUEUE,\s*IMPORT_JOB_RECONCILE_CRON,\s*cronIsTheRetry\]/, - ); + expect(job.scheduleOptions).toBe(cronIsTheRetry); - expect(maintenanceSource).toMatch( - /createAndWork\(\s*boss,\s*IMPORT_JOB_RECONCILE_QUEUE[\s\S]{0,120}handleImportJobReconcileTick/, + expect(await loadedHandler(IMPORT_JOB_RECONCILE_QUEUE)).toBe( + handleImportJobReconcileTick, ); }); }); diff --git a/src/lib/jobs/__tests__/apple-health-import-send-policy.test.ts b/src/lib/jobs/__tests__/apple-health-import-send-policy.test.ts index 8a3ac767b..1d645df5c 100644 --- a/src/lib/jobs/__tests__/apple-health-import-send-policy.test.ts +++ b/src/lib/jobs/__tests__/apple-health-import-send-policy.test.ts @@ -1,6 +1,5 @@ /** - * v1.28.33 (issue #486) — send-policy pins for the Apple Health import - * queue. + * Send-policy pins for the Apple Health import queue (issue #486). * * The queue defaults (retryLimit 2, expireInSeconds 900) redelivered a * long-running import after its staged `/tmp` upload was already @@ -21,20 +20,27 @@ vi.mock("@/lib/db", () => ({ import { APPLE_HEALTH_IMPORT_PARSER_REVISION, - APPLE_HEALTH_IMPORT_LEGACY_QUEUE, APPLE_HEALTH_IMPORT_SEND_OPTIONS, - APPLE_HEALTH_IMPORT_V2_QUEUE, } from "../apple-health-import-worker"; +import { + APPLE_HEALTH_IMPORT_LEGACY_QUEUE, + APPLE_HEALTH_IMPORT_V2_QUEUE, +} from "@/lib/jobs/apple-health-import-queue"; +import { + handleAppleHealthImportJobs, + handleLegacyAppleHealthImportJobs, +} from "@/lib/jobs/reminder/maintenance-handlers"; +import { + groupOf, + jobOf, + loadedHandler, +} from "@/lib/jobs/registry/__tests__/registry-helpers"; const ROUTE_FILES = [ "src/app/api/import/apple-health-export/route.ts", "src/app/api/admin/import-apple-health-export/route.ts", ]; -const maintenanceSource = readFileSync( - join(process.cwd(), "src/lib/jobs/reminder/register-maintenance.ts"), - "utf8", -); const workerSource = readFileSync( join(process.cwd(), "src/lib/jobs/apple-health-import-worker.ts"), "utf8", @@ -80,21 +86,18 @@ describe("apple health import — parser revision boundary", () => { expect(APPLE_HEALTH_IMPORT_LEGACY_QUEUE).toBe("apple-health-import"); }); - it("drains current-parser jobs separately while bridging legacy backlog", () => { - const allQueues = maintenanceSource.match( - /const allQueues\s*=\s*\[([\s\S]*?)\];/, + it("drains current-parser jobs separately while bridging legacy backlog", async () => { + expect(groupOf(APPLE_HEALTH_IMPORT_V2_QUEUE).name).toBe("maintenance"); + expect(groupOf(APPLE_HEALTH_IMPORT_LEGACY_QUEUE).name).toBe("maintenance"); + // Only the current-parser queue runs in the heavy lane; the legacy + // bridge re-sends its backlog onto it. + expect(jobOf(APPLE_HEALTH_IMPORT_V2_QUEUE).lane).toBe(true); + expect(jobOf(APPLE_HEALTH_IMPORT_LEGACY_QUEUE).lane).toBeUndefined(); + expect(await loadedHandler(APPLE_HEALTH_IMPORT_V2_QUEUE)).toBe( + handleAppleHealthImportJobs, ); - expect(allQueues).not.toBeNull(); - expect(allQueues![1]).toMatch(/\bAPPLE_HEALTH_IMPORT_V2_QUEUE\b/); - expect(allQueues![1]).toMatch(/\bAPPLE_HEALTH_IMPORT_LEGACY_QUEUE\b/); - // The v2 window is the wider of the two: its binding wraps the batch in - // a loop that keeps the first failed outcome, so the handler call sits - // further from the queue name than the legacy bridge's does. - expect(maintenanceSource).toMatch( - /createAndWork[\s\S]{0,200}APPLE_HEALTH_IMPORT_V2_QUEUE[\s\S]{0,600}handleAppleHealthImport/, - ); - expect(maintenanceSource).toMatch( - /createAndWork[\s\S]{0,200}APPLE_HEALTH_IMPORT_LEGACY_QUEUE[\s\S]{0,400}migrateLegacyAppleHealthImport/, + expect(await loadedHandler(APPLE_HEALTH_IMPORT_LEGACY_QUEUE)).toBe( + handleLegacyAppleHealthImportJobs, ); }); diff --git a/src/lib/jobs/__tests__/coach-memory-refresh-queue.test.ts b/src/lib/jobs/__tests__/coach-memory-refresh-queue.test.ts index bb6147eab..e9daf5438 100644 --- a/src/lib/jobs/__tests__/coach-memory-refresh-queue.test.ts +++ b/src/lib/jobs/__tests__/coach-memory-refresh-queue.test.ts @@ -1,49 +1,35 @@ /** - * v1.11.1 — queue-registration guard for the combined Coach memory-refresh - * job (rolling conversation summary + durable fact extraction). + * Queue-registration guard for the combined Coach memory-refresh job + * (rolling conversation summary + durable fact extraction). * - * Same source-text-grep approach as the period-narrative / stress-strain - * guards: assert the queue is imported, registered in `allQueues`, and wired - * to a `createAndWork` handler that runs the refresh pipeline — without booting - * pg-boss + Prisma. An unregistered queue silently never runs (the recurring - * past bug this guards against; v1.4.37 W10 caught exactly this class). + * Reads the worker registry: the queue is declared in the status group and + * its loader resolves to the handler that runs the refresh pipeline. An + * unregistered queue silently never runs (the dead-queue class). */ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - import { describe, expect, it } from "vitest"; -// v1.18.1 — the coach-memory-refresh wiring moved out of the 2143-LOC -// reminder-worker boot file into the status registrar. The dead-queue guard -// follows the wiring there. -const REGISTRAR_PATH = join(__dirname, "..", "reminder", "register-status.ts"); -const source = readFileSync(REGISTRAR_PATH, "utf8"); +import { COACH_MEMORY_REFRESH_QUEUE } from "@/lib/ai/coach/coach-memory-shared"; +import { handleCoachMemoryRefresh } from "@/lib/jobs/reminder/status-handlers"; +import { + groupOf, + jobOf, + loadedHandler, +} from "@/lib/jobs/registry/__tests__/registry-helpers"; -function allQueuesBlock(): string { - const m = source.match(/const allQueues\s*=\s*\[([\s\S]*?)\];/); - expect(m).not.toBeNull(); - return m![1]; -} - -describe("reminder-worker — coach-memory-refresh wiring", () => { - it("imports the queue symbol + worker from the coach-memory modules", () => { - expect(source).toMatch( - /from\s*["']@\/lib\/ai\/coach\/coach-memory-shared["']/, - ); - expect(source).toMatch(/\bCOACH_MEMORY_REFRESH_QUEUE\b/); - expect(source).toMatch( - /from\s*["']@\/lib\/ai\/coach\/coach-memory-refresh-worker["']/, - ); - expect(source).toMatch(/\brunCoachMemoryRefresh\b/); +describe("coach-memory-refresh registration", () => { + it("declares the coach-memory-refresh queue in the status group", () => { + expect(groupOf(COACH_MEMORY_REFRESH_QUEUE).name).toBe("status"); }); - it("registers the coach-memory-refresh queue in allQueues", () => { - expect(allQueuesBlock()).toMatch(/\bCOACH_MEMORY_REFRESH_QUEUE\b/); + it("runs one refresh at a time and has no cron", () => { + const job = jobOf(COACH_MEMORY_REFRESH_QUEUE); + expect(job.work).toEqual({ localConcurrency: 1 }); + expect(job.cron).toBeUndefined(); }); - it("registers a createAndWork handler that runs the refresh pipeline", () => { - expect(source).toMatch( - /createAndWork[\s\S]{0,200}COACH_MEMORY_REFRESH_QUEUE[\s\S]{0,600}runCoachMemoryRefresh/, + it("binds the handler that runs the refresh pipeline", async () => { + expect(await loadedHandler(COACH_MEMORY_REFRESH_QUEUE)).toBe( + handleCoachMemoryRefresh, ); }); }); diff --git a/src/lib/jobs/__tests__/coach-message-cleanup-queue.test.ts b/src/lib/jobs/__tests__/coach-message-cleanup-queue.test.ts index 0567b4e40..1999316f6 100644 --- a/src/lib/jobs/__tests__/coach-message-cleanup-queue.test.ts +++ b/src/lib/jobs/__tests__/coach-message-cleanup-queue.test.ts @@ -1,58 +1,50 @@ /** - * v1.18.7 — coach-message-cleanup queue registration guard. + * Coach-message-cleanup queue registration guard. * - * Same source-text-grep approach as the other queue-wiring guards: assert the - * queue is registered in `allQueues`, scheduled, and wired to a `createAndWork` - * handler — without booting pg-boss + Prisma. An unregistered queue silently - * never drains (the recurring v1.4.37 dead-queue bug), which would leave the - * encrypted Coach history growing unbounded with the retention job dark. + * Reads the worker registry: the queue is declared in the maintenance group, + * scheduled with the cron as its retry, and bound to the cleanup handler. An + * unregistered queue silently never drains (the dead-queue class), which + * would leave the encrypted Coach history growing unbounded with the + * retention job dark. */ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -const REGISTRAR_PATH = join( - __dirname, - "..", - "reminder", - "register-maintenance.ts", +import { handleCoachMessageCleanup } from "@/lib/jobs/reminder/cleanup-handlers"; +import { cronIsTheRetry } from "@/lib/jobs/reminder/registrar-shared"; +import { + groupOf, + jobOf, + loadedHandler, +} from "@/lib/jobs/registry/__tests__/registry-helpers"; + +const QUEUE = "coach-message-cleanup"; + +const handlerSource = readFileSync( + join(__dirname, "..", "reminder", "cleanup-handlers.ts"), + "utf8", ); -const workerSource = - readFileSync(REGISTRAR_PATH, "utf8") + - readFileSync( - join(__dirname, "..", "reminder", "cleanup-handlers.ts"), - "utf8", - ); -describe("reminder-worker — coach-message-cleanup wiring", () => { - it("imports the cleanup handler from the cleanup-handlers module", () => { - expect(workerSource).toMatch(/\bhandleCoachMessageCleanup\b/); - expect(workerSource).toMatch(/\bCoachMessageCleanupPayload\b/); +describe("coach-message-cleanup registration", () => { + it("declares the queue in the maintenance group", () => { + expect(groupOf(QUEUE).name).toBe("maintenance"); }); - it("registers the coach-message-cleanup queue in the allQueues loop", () => { - const allQueuesMatch = workerSource.match( - /const allQueues\s*=\s*\[([\s\S]*?)\];/, - ); - expect(allQueuesMatch).not.toBeNull(); - expect(allQueuesMatch![1]).toMatch(/\bCOACH_MESSAGE_CLEANUP_QUEUE\b/); + it("schedules the nightly cron with the cron as the retry", () => { + const job = jobOf(QUEUE); + expect(job.cron).toBe("50 3 * * *"); + expect(job.scheduleOptions).toBe(cronIsTheRetry); + expect(job.work).toEqual({ localConcurrency: 1 }); }); - it("schedules the coach-message-cleanup cron", () => { - expect(workerSource).toMatch( - /\[COACH_MESSAGE_CLEANUP_QUEUE,\s*COACH_MESSAGE_CLEANUP_CRON,\s*cronIsTheRetry\]/, - ); - }); - - it("registers a createAndWork handler for the queue", () => { - expect(workerSource).toMatch( - /createAndWork[\s\S]{0,200}COACH_MESSAGE_CLEANUP_QUEUE[\s\S]{0,200}handleCoachMessageCleanup/, - ); + it("binds the cleanup handler", async () => { + expect(await loadedHandler(QUEUE)).toBe(handleCoachMessageCleanup); }); it("prunes coach messages inside the handler", () => { - expect(workerSource).toMatch( + expect(handlerSource).toMatch( /handleCoachMessageCleanup[\s\S]{0,400}cleanupOldCoachMessages/, ); }); diff --git a/src/lib/jobs/__tests__/cumulative-pr-rederive-queue.test.ts b/src/lib/jobs/__tests__/cumulative-pr-rederive-queue.test.ts index b93282312..96bb7da83 100644 --- a/src/lib/jobs/__tests__/cumulative-pr-rederive-queue.test.ts +++ b/src/lib/jobs/__tests__/cumulative-pr-rederive-queue.test.ts @@ -1,48 +1,46 @@ /** - * v1.30.3 — cumulative-PersonalRecord rederivation queue - * registration guard. + * Cumulative-PersonalRecord rederivation queue registration guard. * - * Same source-text-grep approach as the step-consolidation-repair guard: - * assert the queue is registered in `allQueues`, a `createAndWork` handler is - * wired against it, and the boot-discovery enqueue helper fires — without - * booting pg-boss + Prisma. An unregistered queue silently never drains - * (the v1.4.37 dead-queue class). + * Reads the worker registry: the queue is declared in the rollup group, + * bound to the rederivation handler, and offered again by the rollup boot + * discovery. An unregistered queue silently never drains (the dead-queue + * class). */ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - import { describe, expect, it } from "vitest"; -const REGISTRAR_PATH = join(__dirname, "..", "reminder", "register-rollup.ts"); -const source = readFileSync(REGISTRAR_PATH, "utf8"); +import { handleCumulativePrRederive } from "@/lib/jobs/reminder/rollup-handlers"; +import { + discoveryOf, + groupOf, + jobOf, + loadedHandler, +} from "@/lib/jobs/registry/__tests__/registry-helpers"; +import { + CUMULATIVE_PR_REDERIVE_CONCURRENCY, + CUMULATIVE_PR_REDERIVE_QUEUE, +} from "@/lib/personal-records/cumulative-pr-rederivation"; -describe("reminder-worker — cumulative-pr-rederive wiring", () => { - it("imports the queue symbols from the rederivation module", () => { - expect(source).toMatch( - /from\s*["']@\/lib\/personal-records\/cumulative-pr-rederivation["']/, - ); - expect(source).toMatch(/\bCUMULATIVE_PR_REDERIVE_QUEUE\b/); - expect(source).toMatch(/\brunCumulativePrRederivationForUser\b/); - expect(source).toMatch(/\benqueueBootTimeCumulativePrRederivation\b/); +describe("cumulative-pr-rederive registration", () => { + it("declares the queue in the rollup group", () => { + expect(groupOf(CUMULATIVE_PR_REDERIVE_QUEUE).name).toBe("rollup"); }); - it("registers the queue in the allQueues loop", () => { - const allQueuesMatch = source.match( - /const allQueues\s*=\s*\[([\s\S]*?)\];/, - ); - expect(allQueuesMatch).not.toBeNull(); - expect(allQueuesMatch![1]).toMatch(/\bCUMULATIVE_PR_REDERIVE_QUEUE\b/); + it("is a per-user exclusive one-shot at the module's concurrency", () => { + const job = jobOf(CUMULATIVE_PR_REDERIVE_QUEUE); + expect(job.policy?.policy).toBe("exclusive"); + expect(job.work).toEqual({ + localConcurrency: CUMULATIVE_PR_REDERIVE_CONCURRENCY, + }); + expect(job.cron).toBeUndefined(); }); - it("registers a createAndWork handler against the queue", () => { - expect(source).toMatch( - /createAndWork[\s\S]{0,200}CUMULATIVE_PR_REDERIVE_QUEUE[\s\S]{0,400}runCumulativePrRederivationForUser/, + it("binds the rederivation handler", async () => { + expect(await loadedHandler(CUMULATIVE_PR_REDERIVE_QUEUE)).toBe( + handleCumulativePrRederive, ); }); - it("fires the boot-discovery enqueue helper", () => { - expect(source).toMatch( - /await enqueueBootTimeCumulativePrRederivation\([^)]*\)/, - ); + it("runs the boot discovery pass", () => { + expect(discoveryOf("rollup")).toContain("cumulative-pr-rederive"); }); }); diff --git a/src/lib/jobs/__tests__/cycle-reminder-queue.test.ts b/src/lib/jobs/__tests__/cycle-reminder-queue.test.ts index 8c9158af2..8be8a1395 100644 --- a/src/lib/jobs/__tests__/cycle-reminder-queue.test.ts +++ b/src/lib/jobs/__tests__/cycle-reminder-queue.test.ts @@ -1,67 +1,55 @@ /** - * v1.15 — cycle-reminder queue registration guard. + * Cycle-reminder queue registration guard. * - * Same source-text-grep approach as the mood-reminder / drain / mean- - * consolidation registration guards: assert the cycle-reminder queue is - * declared, listed in `allQueues` (the v1.4.37 dead-queue lesson — an - * unregistered queue is never provisioned and the schedule silently - * no-ops), scheduled on its cron, and wired to a `createAndWork` handler that - * delegates to `runCycleReminderTick`. No pg-boss / Prisma boot. + * Reads the worker registry: the cycle-reminder queue is declared in the + * reminders group (an unregistered queue is never provisioned and the + * schedule silently no-ops), scheduled on its 15-minute cron, and bound to + * the handler that delegates to `runCycleReminderTick`. No pg-boss / Prisma + * boot. */ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -// v1.18.1 — the reminder-dispatch wiring moved out of the 2143-LOC -// reminder-worker boot file into the reminders registrar. The dead-queue guard -// follows the wiring there (the handler module is still concatenated for the -// handler-symbol assertions). -const REGISTRAR_PATH = join( - __dirname, - "..", - "reminder", - "register-reminders.ts", +import { handleCycleReminderCheck } from "@/lib/jobs/reminder/mood-cycle-checks"; +import { + groupOf, + jobOf, + loadedHandler, +} from "@/lib/jobs/registry/__tests__/registry-helpers"; + +const CYCLE_REMINDER_QUEUE = "cycle-reminder-check"; + +const handlerSource = readFileSync( + join(__dirname, "..", "reminder", "mood-cycle-checks.ts"), + "utf8", ); -const source = - readFileSync(REGISTRAR_PATH, "utf8") + - readFileSync( - join(__dirname, "..", "reminder", "mood-cycle-checks.ts"), - "utf8", - ); -describe("reminder-worker — cycle-reminder wiring", () => { +describe("cycle-reminder registration", () => { it("imports the cycle-reminder tick runner", () => { - expect(source).toMatch(/from\s*["']@\/lib\/jobs\/cycle-reminder["']/); - expect(source).toMatch(/\brunCycleReminderTick\b/); + expect(handlerSource).toMatch( + /from\s*["']@\/lib\/jobs\/cycle-reminder["']/, + ); + expect(handlerSource).toMatch(/\brunCycleReminderTick\b/); }); - it("declares the queue + cron constants", () => { - expect(source).toMatch( - /CYCLE_REMINDER_QUEUE\s*=\s*["']cycle-reminder-check["']/, - ); - expect(source).toMatch( - /CYCLE_REMINDER_CRON\s*=\s*["']\*\/15 \* \* \* \*["']/, - ); + it("declares the cycle-reminder queue in the reminders group", () => { + expect(groupOf(CYCLE_REMINDER_QUEUE).name).toBe("reminders"); }); - it("registers the cycle-reminder queue in the allQueues loop", () => { - const allQueuesMatch = source.match( - /const allQueues\s*=\s*\[([\s\S]*?)\];/, - ); - expect(allQueuesMatch).not.toBeNull(); - expect(allQueuesMatch![1]).toMatch(/\bCYCLE_REMINDER_QUEUE\b/); + it("schedules the cycle-reminder cron, single-flight", () => { + const job = jobOf(CYCLE_REMINDER_QUEUE); + expect(job.cron).toBe("*/15 * * * *"); + expect(job.scheduleOptions).toBeUndefined(); + expect(job.work).toEqual({ localConcurrency: 1 }); }); - it("schedules the cycle-reminder cron", () => { - expect(source).toMatch(/\[CYCLE_REMINDER_QUEUE,\s*CYCLE_REMINDER_CRON\]/); - }); - - it("registers a createAndWork handler that runs the cycle-reminder tick", () => { - expect(source).toMatch( - /createAndWork[\s\S]{0,200}CYCLE_REMINDER_QUEUE[\s\S]{0,200}handleCycleReminderCheck/, + it("binds the handler that runs the cycle-reminder tick", async () => { + expect(await loadedHandler(CYCLE_REMINDER_QUEUE)).toBe( + handleCycleReminderCheck, ); - expect(source).toMatch( + expect(handlerSource).toMatch( /handleCycleReminderCheck[\s\S]{0,400}runCycleReminderTick/, ); }); diff --git a/src/lib/jobs/__tests__/daily-briefing.test.ts b/src/lib/jobs/__tests__/daily-briefing.test.ts index 8c7a5ca43..67c01ed9e 100644 --- a/src/lib/jobs/__tests__/daily-briefing.test.ts +++ b/src/lib/jobs/__tests__/daily-briefing.test.ts @@ -1,19 +1,47 @@ /** * Daily-briefing fallback cron: tz slot-gating + result accounting, plus - * the dead-queue wiring guard (imported / in allQueues / scheduled / worked). + * the dead-queue wiring guard read off the worker registry (declared, + * scheduled, bound) and the morning push the finalised refresh fires. */ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - import { describe, it, expect, vi } from "vitest"; + +const { runMorningDigestRefresh, maybeDispatchPush } = vi.hoisted(() => ({ + runMorningDigestRefresh: vi.fn(), + maybeDispatchPush: vi.fn(async () => "sent"), +})); +vi.mock("@/lib/jobs/morning-digest-refresh", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/lib/jobs/morning-digest-refresh") + >()), + runMorningDigestRefresh, +})); +vi.mock("@/lib/daily/daily-briefing-push", async (importOriginal) => ({ + ...(await importOriginal()), + maybeDispatchDailyBriefing: maybeDispatchPush, +})); +vi.mock("@/lib/jobs/reminder/shared", async (importOriginal) => ({ + ...(await importOriginal()), + getWorkerPrisma: () => ({}), +})); import type { PrismaClient } from "@/generated/prisma/client"; +import { runDailyBriefingTick } from "@/lib/jobs/daily-briefing"; import { - runDailyBriefingTick, DAILY_BRIEFING_QUEUE, DAILY_BRIEFING_CRON, -} from "@/lib/jobs/daily-briefing"; +} from "@/lib/jobs/daily-briefing-queue"; import type { maybeDispatchDailyBriefing } from "@/lib/daily/daily-briefing-push"; +import type { MorningDigestRefreshPayload } from "@/lib/jobs/morning-digest-refresh-shared"; +import { + handleDailyBriefing, + handleMorningDigestRefresh, +} from "@/lib/jobs/reminder/status-handlers"; +import { + groupOf, + jobOf, + loadedHandler, +} from "@/lib/jobs/registry/__tests__/registry-helpers"; +import type { Job } from "pg-boss"; // 06:00Z → 08:00 Berlin (the fallback hour) but 02:00 in New York (not the // slot), so a mixed-tz cohort exercises the per-user local-hour gate. @@ -76,38 +104,44 @@ describe("runDailyBriefingTick — slot gating", () => { }); }); -describe("reminder-worker — daily-briefing wiring", () => { - const registrar = readFileSync( - join(__dirname, "..", "reminder", "register-status.ts"), - "utf8", - ); +describe("daily-briefing registration", () => { + function refreshJob(): Job { + return { + id: "j1", + name: "morning-digest-refresh", + data: { userId: "u1", localDate: "2026-07-16" }, + expireInSeconds: 60, + heartbeatSeconds: null, + retryCount: 0, + signal: new AbortController().signal, + }; + } - it("imports the queue symbols from the daily-briefing module", () => { - expect(registrar).toMatch(/from\s*["']@\/lib\/jobs\/daily-briefing["']/); - expect(registrar).toMatch(/\bDAILY_BRIEFING_QUEUE\b/); - expect(registrar).toMatch(/\bDAILY_BRIEFING_CRON\b/); - expect(registrar).toMatch(/\brunDailyBriefingTick\b/); + it("declares the queue in the status group and schedules the cron", () => { + expect(groupOf(DAILY_BRIEFING_QUEUE).name).toBe("status"); + const job = jobOf(DAILY_BRIEFING_QUEUE); + expect(job.cron).toBe(DAILY_BRIEFING_CRON); + expect(job.work).toEqual({ localConcurrency: 1 }); }); - it("registers the queue in allQueues and schedules the cron", () => { - const allQueues = registrar.match(/const allQueues\s*=\s*\[([\s\S]*?)\];/); - expect(allQueues).not.toBeNull(); - expect(allQueues![1]).toMatch(/\bDAILY_BRIEFING_QUEUE\b/); - expect(registrar).toMatch( - /\[DAILY_BRIEFING_QUEUE,\s*DAILY_BRIEFING_CRON\]/, - ); + it("binds the handler that runs the tick", async () => { + expect(await loadedHandler(DAILY_BRIEFING_QUEUE)).toBe(handleDailyBriefing); }); - it("wires a createAndWork handler that runs the tick", () => { - expect(registrar).toMatch( - /createAndWork[\s\S]{0,200}DAILY_BRIEFING_QUEUE[\s\S]{0,400}runDailyBriefingTick/, + it("fires the finalisation-hook push after a finalised morning refresh", async () => { + runMorningDigestRefresh.mockResolvedValueOnce({ status: "finalised" }); + await handleMorningDigestRefresh([refreshJob()]); + expect(maybeDispatchPush).toHaveBeenCalledTimes(1); + expect(maybeDispatchPush).toHaveBeenCalledWith( + expect.anything(), + "u1", + expect.any(Date), ); - }); - it("fires the finalisation-hook push after a finalised morning refresh", () => { - expect(registrar).toMatch( - /result\.status\s*===\s*["']finalised["'][\s\S]{0,300}maybeDispatchDailyBriefing/, - ); + maybeDispatchPush.mockClear(); + runMorningDigestRefresh.mockResolvedValueOnce({ status: "already-final" }); + await handleMorningDigestRefresh([refreshJob()]); + expect(maybeDispatchPush).not.toHaveBeenCalled(); }); it("uses an every-15-min cron for the fallback slot", () => { diff --git a/src/lib/jobs/__tests__/data-arrival-queue.test.ts b/src/lib/jobs/__tests__/data-arrival-queue.test.ts index a86f8057f..b97123667 100644 --- a/src/lib/jobs/__tests__/data-arrival-queue.test.ts +++ b/src/lib/jobs/__tests__/data-arrival-queue.test.ts @@ -1,5 +1,5 @@ /** - * v1.4.37 dead-queue guard for the `data-arrival` queue. + * Dead-queue guard for the `data-arrival` queue. * * Four facts have to hold together or the spine is silently inert: the queue * name is provisioned, a handler drains it, the queue carries a policy that @@ -7,83 +7,65 @@ * is itself registered. Any one of them missing fails open in the worst way — * the emits succeed, the jobs vanish, and nothing anywhere reports a problem. * - * Source-text assertions over the registrar modules, matching the shape of the - * sibling `*-queue.test.ts` guards. + * Reads the worker registry for the queue facts; the retention handler's body + * is read as text. */ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -const statusRegistrar = readFileSync( - join(__dirname, "..", "reminder", "register-status.ts"), +import { DATA_ARRIVAL_QUEUE } from "@/lib/arrivals/emit-shared"; +import { handleDataArrival } from "@/lib/jobs/data-arrival"; +import { handleArrivalReactionCleanup } from "@/lib/jobs/reminder/cleanup-handlers"; +import { cronIsTheRetry } from "@/lib/jobs/reminder/registrar-shared"; +import { + groupOf, + jobOf, + loadedHandler, +} from "@/lib/jobs/registry/__tests__/registry-helpers"; + +const ARRIVAL_REACTION_CLEANUP_QUEUE = "arrival-reaction-cleanup"; + +const cleanupHandlers = readFileSync( + join(__dirname, "..", "reminder", "cleanup-handlers.ts"), "utf8", ); -const maintenanceSource = - readFileSync( - join(__dirname, "..", "reminder", "register-maintenance.ts"), - "utf8", - ) + - readFileSync( - join(__dirname, "..", "reminder", "cleanup-handlers.ts"), - "utf8", - ); - describe("data-arrival queue wiring", () => { - it("provisions the queue in the allQueues list", () => { - const allQueues = statusRegistrar.match( - /const allQueues\s*=\s*\[([\s\S]*?)\];/, - ); - expect(allQueues).not.toBeNull(); - expect(allQueues![1]).toMatch(/\bDATA_ARRIVAL_QUEUE\b/); + it("provisions the queue in the status group", () => { + expect(groupOf(DATA_ARRIVAL_QUEUE).name).toBe("status"); }); - it("binds a createAndWork handler that drains it", () => { - expect(statusRegistrar).toMatch( - /createAndWork[\s\S]{0,200}DATA_ARRIVAL_QUEUE[\s\S]{0,200}handleDataArrival/, - ); + it("binds the handler that drains it", async () => { + expect(await loadedHandler(DATA_ARRIVAL_QUEUE)).toBe(handleDataArrival); }); it("carries the exclusive policy its singleton keys depend on", () => { // Under pg-boss's default `standard` policy NO index covers // `singleton_key`, so the day-scoped keys would coalesce nothing at all and - // a chatty ingest would queue one job per batch. This assertion is anchored - // INSIDE the extracted policy table so an unrelated mention of the queue - // name elsewhere in the file cannot satisfy it. - const table = statusRegistrar.match( - /const queuePolicies: QueuePolicyTable\s*=\s*\{([\s\S]*?)\n\};/, - ); - expect(table).not.toBeNull(); - expect(table![1]).toMatch( - /\[DATA_ARRIVAL_QUEUE\][\s\S]{0,200}policy:\s*"exclusive"/, - ); + // a chatty ingest would queue one job per batch. + expect(jobOf(DATA_ARRIVAL_QUEUE).policy?.policy).toBe("exclusive"); }); it("has no cron schedule — it is send-only, driven by ingest", () => { - // Tolerates the optional third schedule element, so a cron sneaking in - // with send options still trips this. - expect(statusRegistrar).not.toMatch( - /\[\s*DATA_ARRIVAL_QUEUE,\s*\w+_CRON\s*[,\]]/, - ); + const job = jobOf(DATA_ARRIVAL_QUEUE); + expect(job.cron).toBeUndefined(); + expect(job.scheduleOptions).toBeUndefined(); }); - it("registers the retention job that prunes the markers", () => { - const allQueues = maintenanceSource.match( - /const allQueues\s*=\s*\[([\s\S]*?)\];/, - ); - expect(allQueues).not.toBeNull(); - expect(allQueues![1]).toMatch(/\bARRIVAL_REACTION_CLEANUP_QUEUE\b/); - expect(maintenanceSource).toMatch( - /\[\s*ARRIVAL_REACTION_CLEANUP_QUEUE,\s*ARRIVAL_REACTION_CLEANUP_CRON,\s*cronIsTheRetry,?\s*\]/, - ); - expect(maintenanceSource).toMatch( - /createAndWork[\s\S]{0,200}ARRIVAL_REACTION_CLEANUP_QUEUE[\s\S]{0,200}handleArrivalReactionCleanup/, + it("registers the retention job that prunes the markers", async () => { + expect(groupOf(ARRIVAL_REACTION_CLEANUP_QUEUE).name).toBe("maintenance"); + const job = jobOf(ARRIVAL_REACTION_CLEANUP_QUEUE); + expect(job.cron).toBe("10 4 * * *"); + expect(job.scheduleOptions).toBe(cronIsTheRetry); + expect(await loadedHandler(ARRIVAL_REACTION_CLEANUP_QUEUE)).toBe( + handleArrivalReactionCleanup, ); }); it("the retention handler actually deletes arrival reactions by age", () => { - expect(maintenanceSource).toMatch( + expect(cleanupHandlers).toMatch( /handleArrivalReactionCleanup[\s\S]{0,600}arrivalReaction\.deleteMany/, ); }); diff --git a/src/lib/jobs/__tests__/data-backup-send-policy.test.ts b/src/lib/jobs/__tests__/data-backup-send-policy.test.ts index c7e470c91..a24ba53ce 100644 --- a/src/lib/jobs/__tests__/data-backup-send-policy.test.ts +++ b/src/lib/jobs/__tests__/data-backup-send-policy.test.ts @@ -29,7 +29,8 @@ import { import { OFFHOST_BACKUP_QUEUE, OFFHOST_BACKUP_SEND_OPTIONS, -} from "../offhost-backup"; +} from "@/lib/jobs/offhost-backup-queue"; +import { jobOf } from "../registry/__tests__/registry-helpers"; const read = (file: string) => readFileSync(join(process.cwd(), file), "utf8"); @@ -59,13 +60,12 @@ describe("data-backup — pg-boss send policy", () => { }); it("the weekly and nightly schedules carry the policy", () => { - const source = read("src/lib/jobs/reminder/register-maintenance.ts"); - expect(source).toMatch( - /\[\s*DATA_BACKUP_QUEUE,\s*DATA_BACKUP_CRON,\s*DATA_BACKUP_SEND_OPTIONS,?\s*\]/, - ); - expect(source).toMatch( - /\[\s*OFFHOST_BACKUP_QUEUE,\s*OFFHOST_BACKUP_CRON,\s*OFFHOST_BACKUP_SEND_OPTIONS,?\s*\]/, - ); + const weekly = jobOf(DATA_BACKUP_QUEUE); + expect(weekly.cron).toBe("0 3 * * 0"); + expect(weekly.scheduleOptions).toBe(DATA_BACKUP_SEND_OPTIONS); + const nightly = jobOf(OFFHOST_BACKUP_QUEUE); + expect(nightly.cron).toBe("30 2 * * *"); + expect(nightly.scheduleOptions).toBe(OFFHOST_BACKUP_SEND_OPTIONS); }); it("the admin run-now route sends with the same policy", () => { diff --git a/src/lib/jobs/__tests__/document-ai-run.test.ts b/src/lib/jobs/__tests__/document-ai-run.test.ts index 5fc7878f1..3f8bf1757 100644 --- a/src/lib/jobs/__tests__/document-ai-run.test.ts +++ b/src/lib/jobs/__tests__/document-ai-run.test.ts @@ -112,11 +112,11 @@ vi.mock("@/lib/jobs/boss-instance", () => ({ import { AiUnavailableError } from "@/lib/ai/capabilities/refusal"; import { DOCUMENT_AI_RUN_EXPIRE_SECONDS, - DOCUMENT_AI_RUN_QUEUE, enqueueDocumentAiRun, handleDocumentAiRunJobs, runDocumentAiRun, } from "@/lib/jobs/document-ai-run"; +import { DOCUMENT_AI_RUN_QUEUE } from "@/lib/jobs/document-ai-run-queue"; const NOW = 1_800_000_000_000; const now = () => NOW; diff --git a/src/lib/jobs/__tests__/document-index.test.ts b/src/lib/jobs/__tests__/document-index.test.ts index 3d7652ae9..456005ac2 100644 --- a/src/lib/jobs/__tests__/document-index.test.ts +++ b/src/lib/jobs/__tests__/document-index.test.ts @@ -15,11 +15,8 @@ vi.mock("@/lib/documents/auto-stage-labs", () => ({ maybeAutoStageLabFacts: vi.fn().mockResolvedValue(undefined), })); -import { - DOCUMENT_INDEX_QUEUE, - enqueueDocumentIndex, - runDocumentIndex, -} from "../document-index"; +import { enqueueDocumentIndex, runDocumentIndex } from "../document-index"; +import { DOCUMENT_INDEX_QUEUE } from "../document-index-queue"; import { indexDocumentContent } from "@/lib/documents/index-document"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { maybeAutoStageLabFacts } from "@/lib/documents/auto-stage-labs"; diff --git a/src/lib/jobs/__tests__/document-summary-catchup.test.ts b/src/lib/jobs/__tests__/document-summary-catchup.test.ts index 96c7dd4da..111a6afb7 100644 --- a/src/lib/jobs/__tests__/document-summary-catchup.test.ts +++ b/src/lib/jobs/__tests__/document-summary-catchup.test.ts @@ -32,8 +32,8 @@ import { enqueueSummaryCatchUp, runSummaryCatchUpForUser, MAX_ENQUEUES_PER_RUN, - DOCUMENT_SUMMARY_CATCHUP_QUEUE, } from "../document-summary-catchup"; +import { DOCUMENT_SUMMARY_CATCHUP_QUEUE } from "../document-summary-catchup-queue"; import { prisma } from "@/lib/db"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { documentAutoReadEnabled } from "@/lib/documents/document-settings"; diff --git a/src/lib/jobs/__tests__/drain-cumulative-queue.test.ts b/src/lib/jobs/__tests__/drain-cumulative-queue.test.ts index 412d147fd..65c0ecb63 100644 --- a/src/lib/jobs/__tests__/drain-cumulative-queue.test.ts +++ b/src/lib/jobs/__tests__/drain-cumulative-queue.test.ts @@ -1,75 +1,136 @@ /** - * v1.4.37 — pg-boss queue registration for the nightly drain of - * per-sample APPLE_HEALTH cumulative rows. The drain helper exists - * since v1.4.30 but only ran via the admin endpoint / CLI; the - * scheduled wrapper closes the loop so the list view stops painting - * hundreds of step chunks per day. + * pg-boss queue registration for the nightly drain of per-sample + * APPLE_HEALTH cumulative rows. The drain helper once ran only via the admin + * endpoint / CLI; the scheduled wrapper closes the loop so the list view + * stops painting hundreds of step chunks per day. * - * Same source-text-grep approach as the Withings queue regression - * guard so we never have to boot pg-boss + Prisma to assert the - * scheduler wiring stays intact. + * Reads the worker registry (the queue, its cron and its binding) and runs + * the bound handler against mocked passes, so pg-boss and Prisma never boot. */ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; -import { describe, expect, it } from "vitest"; +const drainPerSampleCumulative = vi.hoisted(() => + vi.fn(async () => ({ + stoppedEarly: false, + totals: { + usersScanned: 0, + bucketsCollapsed: 0, + perSampleRowsDeleted: 0, + dailyRowsUpserted: 0, + }, + })), +); +vi.mock( + "@/lib/measurements/drain-per-sample-cumulative", + async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/lib/measurements/drain-per-sample-cumulative") + >()), + drainPerSampleCumulative, + }), +); +vi.mock( + "@/lib/measurements/consolidate-daily-mean", + async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/lib/measurements/consolidate-daily-mean") + >()), + consolidateDailyMean: vi.fn(async () => ({ + stoppedEarly: false, + totals: { + usersScanned: 0, + daysConsolidated: 0, + perSampleRowsSoftDeleted: 0, + dailyRowsUpserted: 0, + daysFailed: 0, + }, + })), + }), +); +vi.mock( + "@/lib/measurements/dense-intraday-retention", + async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/lib/measurements/dense-intraday-retention") + >()), + runDenseIntradayRetention: vi.fn(async () => ({ + stoppedEarly: false, + totals: { + usersScanned: 0, + daysConsolidated: 0, + perSampleRowsSoftDeleted: 0, + hourlyRowsUpserted: 0, + dailyRowsRetired: 0, + }, + })), + }), +); +vi.mock("@/lib/jobs/reminder/shared", async (importOriginal) => ({ + ...(await importOriginal()), + getWorkerPrisma: () => ({}), +})); -// v1.18.1 — the drain-cumulative wiring moved out of the 2143-LOC -// reminder-worker boot file into the rollup registrar. The dead-queue guard -// follows the wiring there. -const REGISTRAR_PATH = join(__dirname, "..", "reminder", "register-rollup.ts"); -const source = readFileSync(REGISTRAR_PATH, "utf8"); +import { + DRAIN_CUMULATIVE_CRON, + DRAIN_CUMULATIVE_QUEUE, +} from "@/lib/jobs/drain-cumulative-queue"; +import { cronIsTheRetry } from "@/lib/jobs/reminder/registrar-shared"; +import { + groupOf, + jobOf, + recordingBoss, +} from "@/lib/jobs/registry/__tests__/registry-helpers"; +import { DRAIN_CUMULATIVE_CUTOFF_HOURS } from "@/lib/measurements/drain-per-sample-cumulative"; -describe("reminder-worker — drainPerSampleCumulative nightly schedule", () => { +describe("drainPerSampleCumulative nightly schedule", () => { it("declares the drain queue at the documented Berlin cadence", () => { - expect(source).toMatch( - /DRAIN_CUMULATIVE_QUEUE\s*=\s*["']drain-per-sample-cumulative["']/, - ); + expect(DRAIN_CUMULATIVE_QUEUE).toBe("drain-per-sample-cumulative"); // 03:45 Europe/Berlin — between audit-log cleanup (03:15) and the // feedback aggregator (04:00). Keep the slot stable so an operator // staring at the cron table can find the job easily. - expect(source).toMatch(/DRAIN_CUMULATIVE_CRON\s*=\s*["']45 3 \* \* \*["']/); + expect(DRAIN_CUMULATIVE_CRON).toBe("45 3 * * *"); }); - it("passes the 36 hour grace window into the drain helper", () => { - // v1.4.38 — the literal `36` lives in the helper module so the - // worker, the admin route, and the CLI all read one source of - // truth. The worker still references the constant by name in the - // cutoffHours slot so the cron's behaviour is unchanged. - expect(source).toMatch(/cutoffHours:\s*DRAIN_CUMULATIVE_CUTOFF_HOURS/); - }); - - it("imports the cutoff constant and the helper from the drain module", () => { - // v1.4.38 — the import is now a multi-symbol form. Match the helper - // and the constant separately so a future contributor who adds a - // third symbol does not break the assertion. - expect(source).toMatch( - /from\s*["']@\/lib\/measurements\/drain-per-sample-cumulative["']/, + it("passes the 36 hour grace window into the drain helper", async () => { + // The literal `36` lives in the helper module so the worker, the admin + // route, and the CLI all read one source of truth. + expect(DRAIN_CUMULATIVE_CUTOFF_HOURS).toBe(36); + const { boss, sends } = recordingBoss(); + const handler = await jobOf(DRAIN_CUMULATIVE_QUEUE).load({ boss }); + await handler([ + { + id: "j1", + name: DRAIN_CUMULATIVE_QUEUE, + data: { triggeredAt: "2026-01-01T03:45:00.000Z" }, + expireInSeconds: 900, + heartbeatSeconds: null, + retryCount: 0, + signal: new AbortController().signal, + }, + ]); + expect(drainPerSampleCumulative).toHaveBeenCalledTimes(1); + expect(drainPerSampleCumulative).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + dryRun: false, + cutoffHours: DRAIN_CUMULATIVE_CUTOFF_HOURS, + }), ); - expect(source).toMatch(/\bdrainPerSampleCumulative\b/); - expect(source).toMatch(/\bDRAIN_CUMULATIVE_CUTOFF_HOURS\b/); + // A tick that finished inside its budget hands nothing on. + expect(sends).toEqual([]); }); - it("registers a createAndWork handler against the drain queue", () => { - expect(source).toMatch( - /createAndWork[\s\S]{0,200}DRAIN_CUMULATIVE_QUEUE[\s\S]{0,1100}drainPerSampleCumulative/, - ); + it("schedules the drain cron with the cron as the retry", () => { + const job = jobOf(DRAIN_CUMULATIVE_QUEUE); + expect(job.cron).toBe(DRAIN_CUMULATIVE_CRON); + expect(job.scheduleOptions).toBe(cronIsTheRetry); + expect(job.work).toEqual({ localConcurrency: 1 }); }); - it("schedules the drain cron via boss.schedule (allQueues + schedules)", () => { - expect(source).toMatch( - /\[DRAIN_CUMULATIVE_QUEUE,\s*DRAIN_CUMULATIVE_CRON,\s*cronIsTheRetry\]/, - ); - }); - - it("registers the drain queue in the allQueues createQueue loop", () => { - // pg-boss v12 requires explicit createQueue before scheduling. The - // allQueues array drives the boot-time `for (const q of allQueues)` - // loop; missing the entry silently no-ops the schedule below. - const allQueuesMatch = source.match( - /const allQueues\s*=\s*\[([\s\S]*?)\];/, - ); - expect(allQueuesMatch).not.toBeNull(); - expect(allQueuesMatch![1]).toMatch(/\bDRAIN_CUMULATIVE_QUEUE\b/); + it("declares the drain queue in the rollup group", () => { + // pg-boss v12 requires explicit createQueue before scheduling; a queue + // the registry does not declare is never created and its schedule + // silently no-ops. + expect(groupOf(DRAIN_CUMULATIVE_QUEUE).name).toBe("rollup"); }); }); diff --git a/src/lib/jobs/__tests__/fitbit-queues.test.ts b/src/lib/jobs/__tests__/fitbit-queues.test.ts deleted file mode 100644 index a9e9fbbc6..000000000 --- a/src/lib/jobs/__tests__/fitbit-queues.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * v1.12.0 — pg-boss queue registration for the Fitbit / Google Health sync layer. - * - * The reminder-worker imports heavy infrastructure (pg-boss, Prisma adapter, - * notification dispatchers, …) so we don't boot the whole worker here. Instead we - * read the source as text and assert the queue constants + cron schedules + - * handler wiring + allQueues + boot-enqueue are present — the fast guard that - * catches the v1.4.37 dead-queue class (a queue declared but never registered in - * `allQueues`, which silently never drains). - * - * Fitbit is poll-only at launch (no webhook — Pub/Sub deferred), so a single - * hourly `fitbit-sync` queue drives the per-user `syncUserFitbit` driver across - * every connection, alongside the backfill + the daily OAuth-state sweep. - */ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - -import { describe, expect, it } from "vitest"; - -// v1.18.1 — the queue wiring + boot discovery moved out of the 2143-LOC -// reminder-worker boot file into domain registrars; the Fitbit queues live in -// the integration-sync registrar. The dead-queue guard follows the wiring there -// (the handler module is still concatenated for the import-routine assertions). -const REGISTRAR_PATH = join( - __dirname, - "..", - "reminder", - "register-integration-sync.ts", -); -const source = - readFileSync(REGISTRAR_PATH, "utf8") + - readFileSync(join(__dirname, "..", "reminder", "fitbit-sync.ts"), "utf8"); - -const FITBIT_QUEUE_CONSTS = [ - "FITBIT_SYNC_QUEUE", - "FITBIT_BACKFILL_QUEUE", - "FITBIT_SLEEP_REPAIR_QUEUE", - "FITBIT_OAUTH_STATE_CLEANUP_QUEUE", -] as const; - -describe("reminder-worker — Fitbit sync queues", () => { - it("declares the Fitbit sync + oauth-state-cleanup queue constants", () => { - expect(source).toMatch(/FITBIT_SYNC_QUEUE\s*=\s*["']fitbit-sync["']/); - expect(source).toMatch( - /FITBIT_OAUTH_STATE_CLEANUP_QUEUE\s*=\s*["']fitbit-oauth-state-cleanup["']/, - ); - }); - - it("registers EVERY Fitbit queue in allQueues (v1.4.37 dead-queue guard)", () => { - const block = /const allQueues = \[([\s\S]*?)\];/.exec(source); - expect(block).not.toBeNull(); - const body = block![1]!; - for (const q of FITBIT_QUEUE_CONSTS) { - expect(body).toContain(q); - } - }); - - it("schedules the hourly poll cron + the daily oauth-state sweep", () => { - expect(source).toMatch(/\[FITBIT_SYNC_QUEUE,\s*FITBIT_SYNC_CRON\]/); - expect(source).toMatch( - /\[\s*FITBIT_OAUTH_STATE_CLEANUP_QUEUE,\s*FITBIT_OAUTH_STATE_CLEANUP_CRON,\s*cronIsTheRetry,?\s*\]/, - ); - }); - - it("registers a createAndWork handler for every Fitbit queue", () => { - expect(source).toMatch( - /createAndWork[\s\S]{0,160}FITBIT_SYNC_QUEUE[\s\S]{0,160}handleFitbitSync/, - ); - expect(source).toMatch( - /createAndWork[\s\S]{0,200}FITBIT_BACKFILL_QUEUE[\s\S]{0,260}enqueueIntegrationBackfillAdmission/, - ); - expect(source).toMatch( - /createAndWork[\s\S]{0,200}FITBIT_SLEEP_REPAIR_QUEUE[\s\S]{0,260}enqueueIntegrationBackfillAdmission/, - ); - expect(source).toMatch( - /createAndWork[\s\S]{0,160}FITBIT_OAUTH_STATE_CLEANUP_QUEUE[\s\S]{0,160}handleFitbitOAuthStateCleanup/, - ); - }); - - it("wires the self-converging Fitbit backfill boot discovery", () => { - expect(source).toMatch( - /enqueueBootTimeFitbitBackfill\(\s*bootStaggerSecondsFor\("fitbit-backfill"\)/, - ); - }); - - it("wires the one-shot Fitbit sleep-repair boot discovery", () => { - expect(source).toMatch( - /enqueueBootTimeFitbitSleepRepair\(\s*bootStaggerSecondsFor\("fitbit-sleep-repair"\)/, - ); - expect(source).toMatch(/from\s*["']@\/lib\/jobs\/fitbit-sleep-repair["']/); - }); - - it("imports the Fitbit sync driver + backfill exports", () => { - expect(source).toMatch(/from\s*["']@\/lib\/fitbit\/sync["']/); - expect(source).toMatch(/from\s*["']@\/lib\/jobs\/fitbit-backfill["']/); - }); -}); diff --git a/src/lib/jobs/__tests__/geo-backfill.test.ts b/src/lib/jobs/__tests__/geo-backfill.test.ts index 8a6b4a835..036401d48 100644 --- a/src/lib/jobs/__tests__/geo-backfill.test.ts +++ b/src/lib/jobs/__tests__/geo-backfill.test.ts @@ -283,13 +283,13 @@ describe("runGeoBackfill", () => { }); /** - * v1.4.37 — the helper is now scheduled via pg-boss at the hourly - * :40 slot. The schedule constants live in the helper module so the - * reminder-worker boot pulls them by import; this test pins both - * shapes so a regression that drops the cadence or drifts the queue - * name lands here, not in production. + * The helper is scheduled via pg-boss at the hourly :40 slot. The + * schedule constants live in the helper module so the worker registry + * pulls them by import; this test pins both shapes so a regression that + * drops the cadence or drifts the queue name lands here, not in + * production. */ -describe("geo-backfill scheduling contract (v1.4.37)", () => { +describe("geo-backfill scheduling contract", () => { it("exports the pg-boss queue name", () => { expect(GEO_BACKFILL_QUEUE).toBe("geo-backfill"); }); @@ -298,20 +298,11 @@ describe("geo-backfill scheduling contract (v1.4.37)", () => { expect(GEO_BACKFILL_CRON).toBe("40 * * * *"); }); - it("reminder-worker imports both the runner and the schedule constants", async () => { - // Source-text probe avoids importing the worker module (which - // would drag pg-boss + a live Postgres connection). The contract - // is binary: the worker either registers the queue + cron or it - // does not; both presence checks pin the v1.4.37 scheduling - // shape end-to-end. + it("the worker registry schedules the queue and binds the ops handler", async () => { + // The handler module's import is read as text; the queue facts come from + // the registry, so neither pg-boss nor a live Postgres connection boots. const fs = await import("node:fs/promises"); const path = await import("node:path"); - // v1.18.1 — the geo-backfill wiring moved out of the 2143-LOC - // reminder-worker boot file into the maintenance registrar. - const workerSrc = await fs.readFile( - path.resolve(__dirname, "..", "reminder", "register-maintenance.ts"), - "utf-8", - ); const handlerSrc = await fs.readFile( path.resolve(__dirname, "..", "reminder", "ops-handlers.ts"), "utf-8", @@ -319,7 +310,11 @@ describe("geo-backfill scheduling contract (v1.4.37)", () => { expect(handlerSrc).toMatch( /import \{[^}]*runGeoBackfill[^}]*\} from "@\/lib\/jobs\/geo-backfill";/, ); - expect(workerSrc).toContain("GEO_BACKFILL_QUEUE, GEO_BACKFILL_CRON"); - expect(workerSrc).toContain("handleGeoBackfill"); + const { groupOf, jobOf, loadedHandler } = + await import("../registry/__tests__/registry-helpers"); + const { handleGeoBackfill } = await import("../reminder/ops-handlers"); + expect(groupOf(GEO_BACKFILL_QUEUE).name).toBe("maintenance"); + expect(jobOf(GEO_BACKFILL_QUEUE).cron).toBe(GEO_BACKFILL_CRON); + expect(await loadedHandler(GEO_BACKFILL_QUEUE)).toBe(handleGeoBackfill); }); }); diff --git a/src/lib/jobs/__tests__/geolite2-fetch-queue.test.ts b/src/lib/jobs/__tests__/geolite2-fetch-queue.test.ts index 7b65fe798..52362b370 100644 --- a/src/lib/jobs/__tests__/geolite2-fetch-queue.test.ts +++ b/src/lib/jobs/__tests__/geolite2-fetch-queue.test.ts @@ -1,10 +1,10 @@ /** * Runtime GeoLite2 fetch (issue #659) — queue-wiring guard + boot gating. * - * The source-text-grep half mirrors the other dead-queue guards: assert the - * queue is imported, in `allQueues`, scheduled, and bound to a `createAndWork` - * handler that runs the fetch — without booting pg-boss. An unregistered queue - * silently never drains (the recurring v1.4.37 dead-queue bug), which here + * The first half reads the worker registry: the queue is declared in the + * maintenance group, scheduled monthly, and bound to the handler that runs + * the fetch, and the maintenance boot discovery carries the fetch pass. An + * unregistered queue silently never drains (the dead-queue class), which here * would mean a keyed self-host never gets its databases fetched. * * The second half exercises the boot-discovery gate directly: it must send a @@ -15,54 +15,46 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const REGISTRAR_PATH = join( - __dirname, - "..", - "reminder", - "register-maintenance.ts", +import { + GEOLITE2_FETCH_CRON, + GEOLITE2_FETCH_QUEUE, +} from "@/lib/jobs/geolite2-fetch"; +import { handleGeolite2Fetch } from "@/lib/jobs/reminder/ops-handlers"; +import { + discoveryOf, + groupOf, + jobOf, + loadedHandler, +} from "@/lib/jobs/registry/__tests__/registry-helpers"; + +const handlerSource = readFileSync( + join(__dirname, "..", "reminder", "ops-handlers.ts"), + "utf8", ); -const workerSource = - readFileSync(REGISTRAR_PATH, "utf8") + - readFileSync(join(__dirname, "..", "reminder", "ops-handlers.ts"), "utf8"); -describe("reminder-worker — geolite2-fetch wiring", () => { - it("imports the queue symbols from the geolite2-fetch module", () => { - expect(workerSource).toMatch(/from\s*["']@\/lib\/jobs\/geolite2-fetch["']/); - expect(workerSource).toMatch(/\bGEOLITE2_FETCH_QUEUE\b/); - expect(workerSource).toMatch(/\bGEOLITE2_FETCH_CRON\b/); - expect(workerSource).toMatch(/\benqueueGeolite2FetchBootDiscovery\b/); +describe("geolite2-fetch registration", () => { + it("declares the queue in the maintenance group", () => { + expect(groupOf(GEOLITE2_FETCH_QUEUE).name).toBe("maintenance"); }); - it("registers the queue in the allQueues loop", () => { - const allQueuesMatch = workerSource.match( - /const allQueues\s*=\s*\[([\s\S]*?)\];/, - ); - expect(allQueuesMatch).not.toBeNull(); - expect(allQueuesMatch![1]).toMatch(/\bGEOLITE2_FETCH_QUEUE\b/); + it("schedules the monthly cron with the default retries", () => { + const job = jobOf(GEOLITE2_FETCH_QUEUE); + expect(job.cron).toBe(GEOLITE2_FETCH_CRON); + expect(job.scheduleOptions).toBeUndefined(); }); - it("schedules the monthly cron", () => { - expect(workerSource).toMatch( - /\[GEOLITE2_FETCH_QUEUE,\s*GEOLITE2_FETCH_CRON\]/, - ); - }); - - it("registers a createAndWork handler for the queue", () => { - expect(workerSource).toMatch( - /createAndWork[\s\S]{0,200}GEOLITE2_FETCH_QUEUE[\s\S]{0,200}handleGeolite2Fetch/, - ); + it("binds the fetch handler", async () => { + expect(await loadedHandler(GEOLITE2_FETCH_QUEUE)).toBe(handleGeolite2Fetch); }); it("runs the fetch inside the handler", () => { - expect(workerSource).toMatch( + expect(handlerSource).toMatch( /handleGeolite2Fetch[\s\S]{0,500}fetchGeoLite2Databases/, ); }); - it("fires the boot discovery from enqueueMaintenanceBootDiscovery", () => { - expect(workerSource).toMatch( - /enqueueMaintenanceBootDiscovery[\s\S]*enqueueGeolite2FetchBootDiscovery/, - ); + it("fires the boot discovery from the maintenance group", () => { + expect(discoveryOf("maintenance")).toContain("geolite2-fetch"); }); }); diff --git a/src/lib/jobs/__tests__/google-health-backfill.test.ts b/src/lib/jobs/__tests__/google-health-backfill.test.ts index 8a8d33ba1..92bc96b99 100644 --- a/src/lib/jobs/__tests__/google-health-backfill.test.ts +++ b/src/lib/jobs/__tests__/google-health-backfill.test.ts @@ -138,11 +138,9 @@ import { LANE_YIELD_DELAY_SECONDS, } from "@/lib/jobs/heavy-lane"; import { GOOGLE_HEALTH_COLLECTION_KEYS } from "@/lib/google-health/collection-keys"; -import { - GOOGLE_HEALTH_DATA_TYPES, - forEachDataPointPage, - runWithGoogleHealthClientOutcome, -} from "@/lib/google-health/client"; +import { forEachDataPointPage } from "@/lib/google-health/data-points"; +import { runWithGoogleHealthClientOutcome } from "@/lib/google-health/budget"; +import { GOOGLE_HEALTH_DATA_TYPES } from "@/lib/google-health/mappers"; beforeEach(() => { vi.clearAllMocks(); @@ -479,7 +477,7 @@ describe("runGoogleHealthBackfillForUser — slices", () => { it("queues the next slice past the waiting job's return after giving way", async () => { resumeState.advances = ["fetchHeartRate"]; syncUserGoogleHealthMock.mockResolvedValue({ imported: 10, failed: true }); - const client = await import("@/lib/google-health/client"); + const client = await import("@/lib/google-health/budget"); const spy = vi .spyOn(client, "googleHealthSliceBudget") .mockImplementation(() => ({ @@ -510,7 +508,7 @@ describe("runGoogleHealthBackfillForUser — slices", () => { syncUserGoogleHealthMock.mockResolvedValue({ imported: 10, failed: true }); // The slice ended the way a resumable walk ends it when Google keeps // answering 429 past every wait. - const client = await import("@/lib/google-health/client"); + const client = await import("@/lib/google-health/budget"); const spy = vi .spyOn(client, "googleHealthSliceBudget") .mockImplementation(() => ({ diff --git a/src/lib/jobs/__tests__/google-health-queues.test.ts b/src/lib/jobs/__tests__/google-health-queues.test.ts index 783593da4..b0c76de06 100644 --- a/src/lib/jobs/__tests__/google-health-queues.test.ts +++ b/src/lib/jobs/__tests__/google-health-queues.test.ts @@ -1,12 +1,11 @@ /** - * v1.26.0 — pg-boss queue registration for the Google Health sync layer. + * pg-boss queue registration for the Google Health sync layer. * - * The reminder-worker imports heavy infrastructure (pg-boss, Prisma adapter, - * notification dispatchers, …) so we don't boot the whole worker here. Instead - * we read the source as text and assert the queue constants + cron schedules + - * handler wiring + allQueues + boot-enqueue are present — the fast guard that - * catches the v1.4.37 dead-queue class (a queue declared but never registered - * in `allQueues`, which silently never drains). + * Reads the worker registry instead of booting the worker: every Google + * Health queue is declared in the integration-sync group with its cron and + * handler, the backfill forwards to the shared admission lane, and the boot + * discovery offers it again. This is the fast guard for the dead-queue class + * (a queue declared but never registered, which silently never drains). * * Google Health is poll-only at launch (no webhook — Pub/Sub deferred), so a * single hourly `google-health-sync` queue drives the per-user @@ -18,75 +17,90 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -const REGISTRAR_PATH = join( - __dirname, - "..", - "reminder", - "register-integration-sync.ts", -); -const source = - readFileSync(REGISTRAR_PATH, "utf8") + - readFileSync( - join(__dirname, "..", "reminder", "google-health-sync.ts"), - "utf8", - ); +import { GOOGLE_HEALTH_BACKFILL_QUEUE } from "@/lib/jobs/google-health-backfill-queue"; +import { INTEGRATION_BACKFILL_ADMISSION_QUEUE } from "@/lib/jobs/integration-backfill-admission"; +import { + GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_CRON, + GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_QUEUE, + GOOGLE_HEALTH_SYNC_CRON, + GOOGLE_HEALTH_SYNC_QUEUE, +} from "@/lib/jobs/integration-poll-queues"; +import { + handleGoogleHealthOAuthStateCleanup, + handleGoogleHealthSync, +} from "@/lib/jobs/reminder/google-health-sync"; +import { cronIsTheRetry } from "@/lib/jobs/reminder/registrar-shared"; +import { + discoveryOf, + groupOf, + jobOf, + loadedHandler, + recordingBoss, +} from "@/lib/jobs/registry/__tests__/registry-helpers"; -const GOOGLE_HEALTH_QUEUE_CONSTS = [ - "GOOGLE_HEALTH_SYNC_QUEUE", - "GOOGLE_HEALTH_BACKFILL_QUEUE", - "GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_QUEUE", +const handlerSource = readFileSync( + join(__dirname, "..", "reminder", "google-health-sync.ts"), + "utf8", +); + +const GOOGLE_HEALTH_QUEUES = [ + GOOGLE_HEALTH_SYNC_QUEUE, + GOOGLE_HEALTH_BACKFILL_QUEUE, + GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_QUEUE, ] as const; -describe("reminder-worker — Google Health sync queues", () => { +describe("Google Health sync queues", () => { it("declares the Google Health sync + oauth-state-cleanup queue constants", () => { - expect(source).toMatch( - /GOOGLE_HEALTH_SYNC_QUEUE\s*=\s*["']google-health-sync["']/, - ); - expect(source).toMatch( - /GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_QUEUE\s*=\s*["']google-health-oauth-state-cleanup["']/, + expect(GOOGLE_HEALTH_SYNC_QUEUE).toBe("google-health-sync"); + expect(GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_QUEUE).toBe( + "google-health-oauth-state-cleanup", ); }); - it("registers EVERY Google Health queue in allQueues (v1.4.37 dead-queue guard)", () => { - const block = /const allQueues = \[([\s\S]*?)\];/.exec(source); - expect(block).not.toBeNull(); - const body = block![1]!; - for (const q of GOOGLE_HEALTH_QUEUE_CONSTS) { - expect(body).toContain(q); + it("declares EVERY Google Health queue in the integration-sync group", () => { + for (const q of GOOGLE_HEALTH_QUEUES) { + expect(groupOf(q).name).toBe("integration_sync"); } }); it("schedules the hourly poll cron + the daily oauth-state sweep", () => { - expect(source).toMatch( - /\[GOOGLE_HEALTH_SYNC_QUEUE,\s*GOOGLE_HEALTH_SYNC_CRON\]/, - ); - expect(source).toMatch( - /\[\s*GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_QUEUE,\s*GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_CRON,\s*cronIsTheRetry,?\s*\]/, - ); + expect(jobOf(GOOGLE_HEALTH_SYNC_QUEUE).cron).toBe(GOOGLE_HEALTH_SYNC_CRON); + expect(jobOf(GOOGLE_HEALTH_SYNC_QUEUE).scheduleOptions).toBeUndefined(); + const sweep = jobOf(GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_QUEUE); + expect(sweep.cron).toBe(GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_CRON); + expect(sweep.scheduleOptions).toBe(cronIsTheRetry); }); - it("registers a createAndWork handler for every Google Health queue", () => { - expect(source).toMatch( - /createAndWork[\s\S]{0,200}GOOGLE_HEALTH_SYNC_QUEUE[\s\S]{0,200}handleGoogleHealthSync/, + it("binds a handler for every Google Health queue", async () => { + expect(await loadedHandler(GOOGLE_HEALTH_SYNC_QUEUE)).toBe( + handleGoogleHealthSync, ); - expect(source).toMatch( - /createAndWork[\s\S]{0,320}GOOGLE_HEALTH_BACKFILL_QUEUE[\s\S]{0,320}enqueueIntegrationBackfillAdmission/, - ); - expect(source).toMatch( - /createAndWork[\s\S]{0,200}GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_QUEUE[\s\S]{0,200}handleGoogleHealthOAuthStateCleanup/, + expect(await loadedHandler(GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_QUEUE)).toBe( + handleGoogleHealthOAuthStateCleanup, ); + const { boss, sends } = recordingBoss(); + const backfill = await jobOf(GOOGLE_HEALTH_BACKFILL_QUEUE).load({ boss }); + await backfill([ + { + id: "j1", + name: GOOGLE_HEALTH_BACKFILL_QUEUE, + data: { userId: "u1" }, + expireInSeconds: 60, + heartbeatSeconds: null, + retryCount: 0, + signal: new AbortController().signal, + }, + ]); + expect(sends).toHaveLength(1); + expect(sends[0]!.queue).toBe(INTEGRATION_BACKFILL_ADMISSION_QUEUE); + expect(sends[0]!.data).toMatchObject({ kind: "google-health-backfill" }); }); it("wires the self-converging Google Health backfill boot discovery", () => { - expect(source).toMatch( - /enqueueBootTimeGoogleHealthBackfill\(\s*bootStaggerSecondsFor\("google-health-backfill"\)/, - ); + expect(discoveryOf("integration_sync")).toContain("google-health-backfill"); }); - it("imports the Google Health sync driver + backfill exports", () => { - expect(source).toMatch(/from\s*["']@\/lib\/google-health\/sync["']/); - expect(source).toMatch( - /from\s*["']@\/lib\/jobs\/google-health-backfill["']/, - ); + it("imports the Google Health sync driver into the handler module", () => { + expect(handlerSource).toMatch(/from\s*["']@\/lib\/google-health\/sync["']/); }); }); diff --git a/src/lib/jobs/__tests__/google-health-sleep-repair.test.ts b/src/lib/jobs/__tests__/google-health-sleep-repair.test.ts index 35d232e7f..f4d56ca51 100644 --- a/src/lib/jobs/__tests__/google-health-sleep-repair.test.ts +++ b/src/lib/jobs/__tests__/google-health-sleep-repair.test.ts @@ -64,16 +64,19 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({ recomputeBucketsForMeasurement: vi.fn(async () => {}), recomputeUserRollups: vi.fn(async () => {}), })); -vi.mock("@/lib/insights/comprehensive-generate", () => ({ +vi.mock("@/lib/insights/status-invalidation", () => ({ invalidateStatusInsightsForTypes: vi.fn(async () => {}), })); vi.mock("@/lib/google-health/credentials", () => ({ getUserGoogleHealthCredentials: vi.fn(async () => null), })); -vi.mock("@/lib/google-health/client", async (importOriginal) => { +vi.mock("@/lib/google-health/oauth", async (importOriginal) => { const actual = - await importOriginal(); - return { ...actual, refreshAccessToken: vi.fn() }; + await importOriginal(); + return { + ...actual, + googleHealthOAuth: { ...actual.googleHealthOAuth, refresh: vi.fn() }, + }; }); import { diff --git a/src/lib/jobs/__tests__/insight-pregenerate.test.ts b/src/lib/jobs/__tests__/insight-pregenerate.test.ts index fd2cda222..f413497c1 100644 --- a/src/lib/jobs/__tests__/insight-pregenerate.test.ts +++ b/src/lib/jobs/__tests__/insight-pregenerate.test.ts @@ -1,5 +1,5 @@ /** - * v1.7.0 — unit tests for the nightly insight pre-generation cron. + * Unit tests for the nightly insight pre-generation cron. * * Covers: * - the discovery query selects stale-cache users who kept AI analysis @@ -14,12 +14,10 @@ * - the warm pass is single-locale + refill-only (v1.16.8); * - the forced single-user warm is idempotent (freshness re-check), * backs off after a failure, and is capped per day (v1.16.8); - * - the queue is registered in `allQueues` AND scheduled in - * reminder-worker.ts (the v1.4.37 W10 unregistered-queue catch). + * - the worker registry declares the queue, schedules its cron and binds + * its handler (the unregistered-queue catch). */ import { describe, it, expect, vi, beforeEach } from "vitest"; -import fs from "node:fs"; -import path from "node:path"; const checkRateLimit = vi.fn(); const getAssistantFlags = vi.fn(); @@ -128,8 +126,18 @@ import { PREGENERATE_STALE_MS, FORCE_WARM_DAILY_LIMIT, INSIGHT_PREGENERATE_QUEUE, - INSIGHT_PREGENERATE_CRON, } from "../insight-pregenerate"; +import { + INSIGHT_PREGENERATE_CRON, + INSIGHT_PREGENERATE_EXPIRE_SECONDS, +} from "../insight-pregenerate-shared"; +import { handleInsightPregenerateJob } from "../reminder/insights-handlers"; +import { insightRetryOptions } from "../reminder/registrar-shared"; +import { + groupOf, + jobOf, + loadedHandler, +} from "../registry/__tests__/registry-helpers"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; function makePrisma( @@ -1402,29 +1410,22 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { }); describe("queue registration", () => { - // v1.18.1 — the insight-pregenerate wiring moved out of the 2143-LOC - // reminder-worker boot file into the status registrar. The dead-queue guard - // follows the wiring there. - const workerSrc = fs.readFileSync( - path.resolve(__dirname, "../reminder/register-status.ts"), - "utf8", - ); - - it("registers the queue name in the allQueues createQueue loop", () => { - const match = workerSrc.match(/const allQueues\s*=\s*\[([\s\S]*?)\];/); - expect(match).not.toBeNull(); - expect(match![1]).toMatch(/\bINSIGHT_PREGENERATE_QUEUE\b/); + it("declares the queue in the status group", () => { + expect(groupOf(INSIGHT_PREGENERATE_QUEUE).name).toBe("status"); }); - it("schedules the cron in the schedules table (with retry policy)", () => { - expect(workerSrc).toMatch( - /\[\s*INSIGHT_PREGENERATE_QUEUE\s*,\s*INSIGHT_PREGENERATE_CRON\s*,\s*\{\s*\.\.\.insightRetryOptions\s*,\s*expireInSeconds:\s*INSIGHT_PREGENERATE_EXPIRE_SECONDS\s*,?\s*\}\s*,?\s*\]/, - ); + it("schedules the cron with the retry policy", () => { + const job = jobOf(INSIGHT_PREGENERATE_QUEUE); + expect(job.cron).toBe(INSIGHT_PREGENERATE_CRON); + expect(job.scheduleOptions).toEqual({ + ...insightRetryOptions, + expireInSeconds: INSIGHT_PREGENERATE_EXPIRE_SECONDS, + }); }); - it("registers a createAndWork handler for the queue", () => { - expect(workerSrc).toMatch( - /createAndWork[\s\S]{0,80}INSIGHT_PREGENERATE_QUEUE/, + it("binds the pre-generation handler to the queue", async () => { + expect(await loadedHandler(INSIGHT_PREGENERATE_QUEUE)).toBe( + handleInsightPregenerateJob, ); }); diff --git a/src/lib/jobs/__tests__/insight-status-generate.test.ts b/src/lib/jobs/__tests__/insight-status-generate.test.ts index da40217a7..cfd66efa4 100644 --- a/src/lib/jobs/__tests__/insight-status-generate.test.ts +++ b/src/lib/jobs/__tests__/insight-status-generate.test.ts @@ -1,18 +1,16 @@ /** - * v1.8.3 — unit tests for the on-demand per-metric status generation queue. + * Unit tests for the on-demand per-metric status generation queue. * * Covers: * - the dispatch runs the matching generator with force:true; * - an unknown metric is annotated and skipped, not thrown; * - the enqueue helper no-ops without a global boss instance and de-dupes * with a singletonKey when one is present; - * - the queue is registered in `allQueues` AND wired to a `createAndWork` - * handler in reminder-worker.ts (the v1.4.37 W10 unregistered-queue - * catch — a queue built but never registered silently never drains). + * - the worker registry declares the queue and binds its handler (the + * unregistered-queue catch — a queue built but never registered silently + * never drains). */ import { describe, it, expect, vi, beforeEach } from "vitest"; -import fs from "node:fs"; -import path from "node:path"; const getGlobalBoss = vi.fn(); vi.mock("@/lib/jobs/boss-instance", () => ({ @@ -47,6 +45,12 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityForJob: (...a: unknown[]) => aiCapabilityForJob(...a), aiCapabilityForRecord: vi.fn(), })); +// The worker's insights handlers (loaded to check the queue's binding) also +// import the nightly status batch; stub it so its card table never reads the +// stubbed generators above. +vi.mock("@/lib/insights/status-batch", () => ({ + generateStatusBatchForUser: vi.fn(), +})); const annotate = vi.fn(); vi.mock("@/lib/logging/context", async (importOriginal) => ({ ...(await importOriginal()), @@ -59,6 +63,13 @@ import { INSIGHT_STATUS_GENERATE_QUEUE, INSIGHT_STATUS_METRICS, } from "../insight-status-generate"; +import { INSIGHT_STATUS_GENERATE_CONCURRENCY } from "../insight-status-generate-queue"; +import { handleInsightStatusGenerate } from "../reminder/insights-handlers"; +import { + groupOf, + jobOf, + loadedHandler, +} from "../registry/__tests__/registry-helpers"; beforeEach(() => { vi.clearAllMocks(); @@ -259,23 +270,18 @@ describe("enqueueStatusGeneration", () => { }); describe("queue registration", () => { - // v1.18.1 — the insight-status-generate wiring moved out of the 2143-LOC - // reminder-worker boot file into the status registrar. The dead-queue guard - // follows the wiring there. - const workerSrc = fs.readFileSync( - path.resolve(__dirname, "../reminder/register-status.ts"), - "utf8", - ); - - it("registers the queue name in the allQueues createQueue loop", () => { - const match = workerSrc.match(/const allQueues\s*=\s*\[([\s\S]*?)\];/); - expect(match).not.toBeNull(); - expect(match![1]).toMatch(/\bINSIGHT_STATUS_GENERATE_QUEUE\b/); + it("declares the queue in the status group, low-concurrency and cron-free", () => { + expect(groupOf(INSIGHT_STATUS_GENERATE_QUEUE).name).toBe("status"); + const job = jobOf(INSIGHT_STATUS_GENERATE_QUEUE); + expect(job.work).toEqual({ + localConcurrency: INSIGHT_STATUS_GENERATE_CONCURRENCY, + }); + expect(job.cron).toBeUndefined(); }); - it("registers a createAndWork handler for the queue", () => { - expect(workerSrc).toMatch( - /createAndWork[\s\S]{0,120}INSIGHT_STATUS_GENERATE_QUEUE/, + it("binds the status handler to the queue", async () => { + expect(await loadedHandler(INSIGHT_STATUS_GENERATE_QUEUE)).toBe( + handleInsightStatusGenerate, ); }); diff --git a/src/lib/jobs/__tests__/intake-auto-skip.test.ts b/src/lib/jobs/__tests__/intake-auto-skip.test.ts index 4073e96d1..05ad666a0 100644 --- a/src/lib/jobs/__tests__/intake-auto-skip.test.ts +++ b/src/lib/jobs/__tests__/intake-auto-skip.test.ts @@ -27,12 +27,14 @@ import type { PrismaClient } from "@/generated/prisma/client"; import { caches, __resetAllCachesForTests } from "@/lib/cache/server-cache"; import { - INTAKE_AUTO_SKIP_CRON, INTAKE_AUTO_SKIP_GRACE_HOURS, - INTAKE_AUTO_SKIP_QUEUE, medicationAutoMissDelayMs, runIntakeAutoSkipPass, } from "../intake-auto-skip"; +import { + INTAKE_AUTO_SKIP_CRON, + INTAKE_AUTO_SKIP_QUEUE, +} from "../intake-auto-skip-queue"; interface FakeIntakeRow { id: string; diff --git a/src/lib/jobs/__tests__/intake-auto-take.test.ts b/src/lib/jobs/__tests__/intake-auto-take.test.ts index db93d7c97..bee2b010c 100644 --- a/src/lib/jobs/__tests__/intake-auto-take.test.ts +++ b/src/lib/jobs/__tests__/intake-auto-take.test.ts @@ -39,15 +39,17 @@ import { consumeForIntake } from "@/lib/medications/inventory/consumption"; import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh"; import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; import { - INTAKE_AUTO_TAKE_CRON, INTAKE_AUTO_TAKE_LOOKBACK_MS, - INTAKE_AUTO_TAKE_QUEUE, autoTakeFloor, decideSlot, dueAutoTakeSlots, isInPauseEra, runIntakeAutoTakePass, } from "../intake-auto-take"; +import { + INTAKE_AUTO_TAKE_CRON, + INTAKE_AUTO_TAKE_QUEUE, +} from "../intake-auto-take-queue"; import { autoTakeOwnedFrom } from "../intake-auto-skip"; import { autoTakeRollingAnchor } from "@/lib/medications/auto-take"; import { computeDisplayDue } from "@/lib/medications/scheduling/next-due"; diff --git a/src/lib/jobs/__tests__/intake-slot-dedup-queue.test.ts b/src/lib/jobs/__tests__/intake-slot-dedup-queue.test.ts index a99672474..3f5f2dc6b 100644 --- a/src/lib/jobs/__tests__/intake-slot-dedup-queue.test.ts +++ b/src/lib/jobs/__tests__/intake-slot-dedup-queue.test.ts @@ -1,95 +1,102 @@ /** - * v1.8.2 — duplicate dose-slot dedup queue registration guard. + * Duplicate dose-slot dedup queue registration guard. * - * Same source-text-grep approach as the drain + step-consolidation + - * mean-consolidation regression guards: assert the queue is registered - * in `allQueues`, a `createAndWork` handler is wired against it, and the - * boot-discovery enqueue helper is called — without booting pg-boss + - * Prisma. An unregistered queue silently never drains (the past incident - * this guard exists to prevent). + * Reads the worker registry: the queue is declared in the maintenance group + * with its daily cron, bound to the dedup handler, and offered again by the + * maintenance boot discovery. The bound handler runs against a mocked dedup + * module, so pg-boss and Prisma never boot. An unregistered queue silently + * never drains (the past incident this guard exists to prevent). */ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; -import { describe, expect, it } from "vitest"; - -// v1.18.1 — the intake-slot-dedup wiring + boot discovery moved out of the -// 2143-LOC reminder-worker boot file into the maintenance registrar. The -// dead-queue guard follows the wiring there. -const REGISTRAR_PATH = join( - __dirname, - "..", - "reminder", - "register-maintenance.ts", +const { dedupeUserIntakeSlots, enqueueBootTimeIntakeSlotDedup } = vi.hoisted( + () => ({ + dedupeUserIntakeSlots: vi.fn(async () => ({ + slotsCollapsed: 0, + rowsSoftDeleted: 0, + rowsNormalised: 0, + daysRecomputed: 0, + })), + enqueueBootTimeIntakeSlotDedup: vi.fn(async () => ({ + enqueued: 0, + skipped: 0, + error: null, + })), + }), ); -const source = readFileSync(REGISTRAR_PATH, "utf8"); +vi.mock("@/lib/medications/intake-slot-dedup", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/lib/medications/intake-slot-dedup") + >()), + dedupeUserIntakeSlots, + enqueueBootTimeIntakeSlotDedup, +})); -describe("reminder-worker — intake-slot-dedup wiring", () => { - it("imports the queue symbols from the intake-slot-dedup module", () => { - expect(source).toMatch( - /from\s*["']@\/lib\/medications\/intake-slot-dedup["']/, +import type { Job } from "pg-boss"; + +import { handleIntakeSlotDedup } from "@/lib/jobs/reminder/maintenance-handlers"; +import { + discoveryOf, + groupOf, + jobOf, + loadedHandler, +} from "@/lib/jobs/registry/__tests__/registry-helpers"; +import { + INTAKE_SLOT_DEDUP_CONCURRENCY, + INTAKE_SLOT_DEDUP_QUEUE, +} from "@/lib/medications/intake-slot-dedup"; + +function job(data: { userId?: string }): Job<{ userId?: string }> { + return { + id: "j1", + name: INTAKE_SLOT_DEDUP_QUEUE, + data, + expireInSeconds: 60, + heartbeatSeconds: null, + retryCount: 0, + signal: new AbortController().signal, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("intake-slot-dedup registration", () => { + it("declares the intake-slot-dedup queue in the maintenance group", () => { + expect(groupOf(INTAKE_SLOT_DEDUP_QUEUE).name).toBe("maintenance"); + const definition = jobOf(INTAKE_SLOT_DEDUP_QUEUE); + expect(definition.policy?.policy).toBe("exclusive"); + expect(definition.work).toEqual({ + localConcurrency: INTAKE_SLOT_DEDUP_CONCURRENCY, + }); + }); + + it("binds the dedup handler, which runs the per-user dedup", async () => { + expect(await loadedHandler(INTAKE_SLOT_DEDUP_QUEUE)).toBe( + handleIntakeSlotDedup, ); - expect(source).toMatch(/\bINTAKE_SLOT_DEDUP_QUEUE\b/); - expect(source).toMatch(/\bdedupeUserIntakeSlots\b/); - expect(source).toMatch(/\benqueueBootTimeIntakeSlotDedup\b/); + await handleIntakeSlotDedup([job({ userId: "u1" })]); + expect(dedupeUserIntakeSlots).toHaveBeenCalledWith("u1"); + expect(enqueueBootTimeIntakeSlotDedup).not.toHaveBeenCalled(); }); - it("registers the intake-slot-dedup queue in the allQueues loop", () => { - const allQueuesMatch = source.match( - /const allQueues\s*=\s*\[([\s\S]*?)\];/, - ); - expect(allQueuesMatch).not.toBeNull(); - expect(allQueuesMatch![1]).toMatch(/\bINTAKE_SLOT_DEDUP_QUEUE\b/); + it("fires the boot-discovery pass", () => { + expect(discoveryOf("maintenance")).toContain("intake-slot-dedup"); }); - it("registers a createAndWork handler against the intake-slot-dedup queue", () => { - // v1.15.19 — span widened from {0,400}: the handler gained the daily - // cron-tick dispatch branch (no `userId` → discovery fan-out) between - // the queue name and the per-user dedup call. - expect(source).toMatch( - /createAndWork[\s\S]{0,200}INTAKE_SLOT_DEDUP_QUEUE[\s\S]{0,1400}dedupeUserIntakeSlots/, - ); - }); - - it("fires the boot-discovery enqueue helper", () => { - expect(source).toMatch(/await enqueueBootTimeIntakeSlotDedup\(\)/); - }); - - // v1.15.19 — the boot-only pass left duplicates created between deploys - // standing until the next worker restart. The queue now also carries a - // daily cron tick whose empty payload (no `userId`) dispatches the same - // discovery fan-out. + // The boot-only pass left duplicates created between deploys standing + // until the next worker restart. The queue also carries a daily cron tick + // whose empty payload (no `userId`) dispatches the same discovery fan-out. it("declares a daily cron inside the 03:xx maintenance window", () => { - const cronMatch = source.match( - /const INTAKE_SLOT_DEDUP_CRON\s*=\s*["']([^"']+)["']/, - ); - expect(cronMatch).not.toBeNull(); // Five-field cron, daily (day-of-month/month/day-of-week all `*`), // inside the 03:xx maintenance window. - expect(cronMatch![1]).toMatch(/^\d{1,2} 3 \* \* \*$/); + expect(jobOf(INTAKE_SLOT_DEDUP_QUEUE).cron).toMatch(/^\d{1,2} 3 \* \* \*$/); }); - it("registers the cron in the schedules table", () => { - // v1.15.20 — the schedules tuple grew an optional send-options slot - // (retry policy for the insight queues); match on the assignment, not - // the exact tuple type. - // v1.18.1 — `schedules` is now a module-level const in the registrar, so it - // closes at column 0 (`\n];`) rather than the monolith's function-body - // two-space indent. - const schedulesMatch = source.match( - /const schedules:[^=]*=\s*\[([\s\S]*?)\n\];/, - ); - expect(schedulesMatch).not.toBeNull(); - expect(schedulesMatch![1]).toMatch( - /\[INTAKE_SLOT_DEDUP_QUEUE,\s*INTAKE_SLOT_DEDUP_CRON\]/, - ); - }); - - it("dispatches a payload without userId to the discovery fan-out", () => { - // The handler must branch on the missing `userId` BEFORE the per-user - // dedup call, and the branch must run the discovery helper. - expect(source).toMatch( - /INTAKE_SLOT_DEDUP_QUEUE[\s\S]{0,800}if \(!userId\) \{[\s\S]{0,400}enqueueBootTimeIntakeSlotDedup\(\)/, - ); + it("dispatches a payload without userId to the discovery fan-out", async () => { + await handleIntakeSlotDedup([job({})]); + expect(enqueueBootTimeIntakeSlotDedup).toHaveBeenCalledTimes(1); + expect(dedupeUserIntakeSlots).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/jobs/__tests__/integration-backfill-admission.test.ts b/src/lib/jobs/__tests__/integration-backfill-admission.test.ts index a26bd6561..32126000b 100644 --- a/src/lib/jobs/__tests__/integration-backfill-admission.test.ts +++ b/src/lib/jobs/__tests__/integration-backfill-admission.test.ts @@ -1,7 +1,74 @@ +/** + * The shared full-history backfill admission: every provider backfill and + * repair forwards into one database-coordinated drain. The boot discovery, + * the drain's binding and the source queues' forwarding are read off the + * worker registry; the boot discovery runs against stubbed enqueue helpers + * that record the stagger each pass hands them. + */ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import type { SendOptions } from "pg-boss"; + +const staggers = vi.hoisted(() => new Map()); +const recordStagger = vi.hoisted(() => (kind: string, stagger: number) => { + staggers.set(kind, stagger); + return { enqueued: 0, skipped: 0, error: null }; +}); +vi.mock("@/lib/jobs/whoop-backfill", async (importOriginal) => ({ + ...(await importOriginal()), + enqueueBootTimeWhoopBackfill: async (stagger: number) => + recordStagger("whoop-backfill", stagger), +})); +vi.mock("@/lib/fitbit/jobs/backfill", async (importOriginal) => ({ + ...(await importOriginal()), + enqueueBootTimeFitbitBackfill: async (stagger: number) => + recordStagger("fitbit-backfill", stagger), +})); +vi.mock("@/lib/jobs/google-health-backfill", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/lib/jobs/google-health-backfill") + >()), + enqueueBootTimeGoogleHealthBackfill: async (stagger: number) => + recordStagger("google-health-backfill", stagger), +})); +vi.mock("@/lib/jobs/google-health-sleep-repair", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/lib/jobs/google-health-sleep-repair") + >()), + enqueueBootTimeGoogleHealthSleepRepair: async (stagger: number) => + recordStagger("google-health-sleep-repair", stagger), +})); +vi.mock("@/lib/jobs/sleep-timeline-backfill", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/lib/jobs/sleep-timeline-backfill") + >()), + enqueueBootTimeSleepTimelineBackfill: async (stagger: number) => + recordStagger("sleep-timeline-backfill", stagger), +})); +vi.mock("@/lib/jobs/lab-biomarker-backfill", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/lib/jobs/lab-biomarker-backfill") + >()), + enqueueBootTimeLabBiomarkerBackfill: async (stagger: number) => + recordStagger("lab-biomarker-backfill", stagger), +})); +vi.mock("@/lib/jobs/strava-backfill", async (importOriginal) => ({ + ...(await importOriginal()), + enqueueBootTimeStravaBackfill: async (stagger: number) => + recordStagger("strava-backfill", stagger), +})); +vi.mock("@/lib/fitbit/jobs/sleep-repair", async (importOriginal) => ({ + ...(await importOriginal()), + enqueueBootTimeFitbitSleepRepair: async (stagger: number) => + recordStagger("fitbit-sleep-repair", stagger), +})); +vi.mock("@/lib/jobs/oura-backfill", async (importOriginal) => ({ + ...(await importOriginal()), + enqueueBootTimeOuraBackfill: async (stagger: number) => + recordStagger("oura-backfill", stagger), +})); + import { INTEGRATION_BACKFILL_ADMISSION_GROUP, INTEGRATION_BACKFILL_ADMISSION_QUEUE, @@ -12,27 +79,44 @@ import { bootStaggerSecondsFor, enqueueIntegrationBackfillAdmission, integrationBackfillSourceOptions, + type IntegrationBackfillKind, } from "../integration-backfill-admission"; - -const registrarSource = readFileSync( - join(__dirname, "../reminder/register-integration-sync.ts"), - "utf8", -); +import { + FITBIT_BACKFILL_QUEUE, + FITBIT_SLEEP_REPAIR_QUEUE, + FITBIT_SYNC_QUEUE, +} from "@/lib/fitbit/jobs/queues"; +import { GOOGLE_HEALTH_BACKFILL_QUEUE } from "../google-health-backfill-queue"; +import { GOOGLE_HEALTH_SLEEP_REPAIR_QUEUE } from "../google-health-sleep-repair-queue"; +import { + GOOGLE_HEALTH_SYNC_QUEUE, + STRAVA_SYNC_QUEUE, +} from "../integration-poll-queues"; +import { LAB_BIOMARKER_BACKFILL_QUEUE } from "../lab-biomarker-backfill"; +import { OURA_BACKFILL_QUEUE } from "../oura-backfill-queue"; +import { + groupNamed, + jobOf, + recordingBoss, +} from "../registry/__tests__/registry-helpers"; +import { SLEEP_TIMELINE_BACKFILL_QUEUE } from "../sleep-timeline-backfill-queue"; +import { STRAVA_BACKFILL_QUEUE } from "../strava-backfill-queue"; +import { WHOOP_BACKFILL_QUEUE } from "../whoop-backfill-queue"; const providerSources = [ "whoop-backfill.ts", - "fitbit-backfill.ts", + "../fitbit/jobs/backfill.ts", "google-health-backfill.ts", "google-health-sleep-repair.ts", "sleep-timeline-backfill.ts", "lab-biomarker-backfill.ts", "strava-backfill.ts", - "fitbit-sleep-repair.ts", + "../fitbit/jobs/sleep-repair.ts", "oura-backfill.ts", ].map((file) => readFileSync(join(__dirname, "..", file), "utf8")); describe("shared full-history backfill admission", () => { - it("assigns every integration backfill a deterministic durable boot stagger", () => { + it("assigns every integration backfill a deterministic durable boot stagger", async () => { expect(INTEGRATION_BACKFILL_BOOT_ORDER).toEqual([ "whoop-backfill", "fitbit-backfill", @@ -54,23 +138,14 @@ describe("shared full-history backfill admission", () => { expect(source).toMatch(/startAfterSeconds:\s*number\s*=\s*0/); } - const discoveryCalls = { - "whoop-backfill": "enqueueBootTimeWhoopBackfill", - "fitbit-backfill": "enqueueBootTimeFitbitBackfill", - "google-health-backfill": "enqueueBootTimeGoogleHealthBackfill", - "google-health-sleep-repair": "enqueueBootTimeGoogleHealthSleepRepair", - "sleep-timeline-backfill": "enqueueBootTimeSleepTimelineBackfill", - "lab-biomarker-backfill": "enqueueBootTimeLabBiomarkerBackfill", - "strava-backfill": "enqueueBootTimeStravaBackfill", - "fitbit-sleep-repair": "enqueueBootTimeFitbitSleepRepair", - "oura-backfill": "enqueueBootTimeOuraBackfill", - } as const; + // Every kind has a boot discovery pass, and each pass hands its helper + // the kind's own stagger. + const passes = groupNamed("integration_sync").discovery ?? []; for (const kind of INTEGRATION_BACKFILL_BOOT_ORDER) { - expect(registrarSource).toMatch( - new RegExp( - `${discoveryCalls[kind]}\\(\\s*bootStaggerSecondsFor\\("${kind}"\\)`, - ), - ); + const pass = passes.find((p) => p.name === kind); + expect(pass, kind).toBeDefined(); + await pass!.run(); + expect(staggers.get(kind), kind).toBe(bootStaggerSecondsFor(kind)); } }); @@ -139,40 +214,51 @@ describe("shared full-history backfill admission", () => { ).not.toHaveProperty("startAfter"); }); - it("registers one shared globally bounded drain and leaves incremental workers outside it", () => { - expect(registrarSource).toMatch( - /INTEGRATION_BACKFILL_ADMISSION_QUEUE[\s\S]{0,220}localConcurrency:\s*INTEGRATION_BACKFILL_GLOBAL_CONCURRENCY[\s\S]{0,120}groupConcurrency:\s*INTEGRATION_BACKFILL_GLOBAL_CONCURRENCY/, - ); - expect(registrarSource).toMatch( - /\[INTEGRATION_BACKFILL_ADMISSION_QUEUE\]:\s*\{[\s\S]{0,100}policy:\s*"exclusive"/, - ); + it("registers one shared globally bounded drain and leaves incremental workers outside it", async () => { + const drain = jobOf(INTEGRATION_BACKFILL_ADMISSION_QUEUE); + expect(drain.work).toEqual({ + localConcurrency: INTEGRATION_BACKFILL_GLOBAL_CONCURRENCY, + groupConcurrency: INTEGRATION_BACKFILL_GLOBAL_CONCURRENCY, + }); + expect(drain.policy?.policy).toBe("exclusive"); - for (const queue of [ - "WHOOP_BACKFILL_QUEUE", - "FITBIT_BACKFILL_QUEUE", - "GOOGLE_HEALTH_BACKFILL_QUEUE", - "GOOGLE_HEALTH_SLEEP_REPAIR_QUEUE", - "SLEEP_TIMELINE_BACKFILL_QUEUE", - "LAB_BIOMARKER_BACKFILL_QUEUE", - "STRAVA_BACKFILL_QUEUE", - "FITBIT_SLEEP_REPAIR_QUEUE", - "OURA_BACKFILL_QUEUE", - ]) { - expect(registrarSource).toMatch( - new RegExp( - `${queue}[\\s\\S]{0,500}enqueueIntegrationBackfillAdmission`, - ), - ); + const sources: ReadonlyArray<[string, IntegrationBackfillKind]> = [ + [WHOOP_BACKFILL_QUEUE, "whoop-backfill"], + [FITBIT_BACKFILL_QUEUE, "fitbit-backfill"], + [GOOGLE_HEALTH_BACKFILL_QUEUE, "google-health-backfill"], + [GOOGLE_HEALTH_SLEEP_REPAIR_QUEUE, "google-health-sleep-repair"], + [SLEEP_TIMELINE_BACKFILL_QUEUE, "sleep-timeline-backfill"], + [LAB_BIOMARKER_BACKFILL_QUEUE, "lab-biomarker-backfill"], + [STRAVA_BACKFILL_QUEUE, "strava-backfill"], + [FITBIT_SLEEP_REPAIR_QUEUE, "fitbit-sleep-repair"], + [OURA_BACKFILL_QUEUE, "oura-backfill"], + ]; + for (const [queue, kind] of sources) { + const { boss, sends } = recordingBoss(); + const handler = await jobOf(queue).load({ boss }); + await handler([ + { + id: "j1", + name: queue, + data: { userId: "u1", provider: "WHOOP" }, + expireInSeconds: 60, + heartbeatSeconds: null, + retryCount: 0, + signal: new AbortController().signal, + }, + ]); + expect(sends, queue).toHaveLength(1); + expect(sends[0]!.queue).toBe(INTEGRATION_BACKFILL_ADMISSION_QUEUE); + expect(sends[0]!.data).toMatchObject({ kind }); } for (const queue of [ - "FITBIT_SYNC_QUEUE", - "GOOGLE_HEALTH_SYNC_QUEUE", - "STRAVA_SYNC_QUEUE", + FITBIT_SYNC_QUEUE, + GOOGLE_HEALTH_SYNC_QUEUE, + STRAVA_SYNC_QUEUE, ]) { - expect(registrarSource).toMatch( - new RegExp(`${queue}[\\s\\S]{0,100}localConcurrency:\\s*1`), - ); + expect(jobOf(queue).work, queue).toEqual({ localConcurrency: 1 }); + expect(jobOf(queue).lane, queue).toBeUndefined(); } }); }); diff --git a/src/lib/jobs/__tests__/mcp-token-cleanup-queue.test.ts b/src/lib/jobs/__tests__/mcp-token-cleanup-queue.test.ts index 7f42958a7..85babde37 100644 --- a/src/lib/jobs/__tests__/mcp-token-cleanup-queue.test.ts +++ b/src/lib/jobs/__tests__/mcp-token-cleanup-queue.test.ts @@ -1,57 +1,49 @@ /** - * MCP Phase 3 (M2) — mcp-token-cleanup queue registration guard. + * Mcp-token-cleanup queue registration guard. * - * Source-text-grep guard like the other queue-wiring checks: assert the queue - * is in `allQueues`, scheduled, and wired to a `createAndWork` handler. An - * unregistered queue silently never drains (the recurring v1.4.37 dead-queue - * bug), which would leave expired MCP connector access tokens growing unbounded. + * Reads the worker registry: the queue is declared in the maintenance group, + * scheduled with the cron as its retry, and bound to the cleanup handler. An + * unregistered queue silently never drains (the dead-queue class), which + * would leave expired MCP connector access tokens growing unbounded. */ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -const REGISTRAR_PATH = join( - __dirname, - "..", - "reminder", - "register-maintenance.ts", +import { handleMcpTokenCleanup } from "@/lib/jobs/reminder/cleanup-handlers"; +import { cronIsTheRetry } from "@/lib/jobs/reminder/registrar-shared"; +import { + groupOf, + jobOf, + loadedHandler, +} from "@/lib/jobs/registry/__tests__/registry-helpers"; + +const QUEUE = "mcp-token-cleanup"; + +const handlerSource = readFileSync( + join(__dirname, "..", "reminder", "cleanup-handlers.ts"), + "utf8", ); -const workerSource = - readFileSync(REGISTRAR_PATH, "utf8") + - readFileSync( - join(__dirname, "..", "reminder", "cleanup-handlers.ts"), - "utf8", - ); -describe("reminder-worker — mcp-token-cleanup wiring", () => { - it("imports the cleanup handler from the cleanup-handlers module", () => { - expect(workerSource).toMatch(/\bhandleMcpTokenCleanup\b/); - expect(workerSource).toMatch(/\bMcpTokenCleanupPayload\b/); +describe("mcp-token-cleanup registration", () => { + it("declares the queue in the maintenance group", () => { + expect(groupOf(QUEUE).name).toBe("maintenance"); }); - it("registers the mcp-token-cleanup queue in the allQueues loop", () => { - const allQueuesMatch = workerSource.match( - /const allQueues\s*=\s*\[([\s\S]*?)\];/, - ); - expect(allQueuesMatch).not.toBeNull(); - expect(allQueuesMatch![1]).toMatch(/\bMCP_TOKEN_CLEANUP_QUEUE\b/); + it("schedules the daily cron with the cron as the retry", () => { + const job = jobOf(QUEUE); + expect(job.cron).toBe("0 4 * * *"); + expect(job.scheduleOptions).toBe(cronIsTheRetry); + expect(job.work).toEqual({ localConcurrency: 1 }); }); - it("schedules the mcp-token-cleanup cron", () => { - expect(workerSource).toMatch( - /\[MCP_TOKEN_CLEANUP_QUEUE,\s*MCP_TOKEN_CLEANUP_CRON,\s*cronIsTheRetry\]/, - ); - }); - - it("registers a createAndWork handler for the queue", () => { - expect(workerSource).toMatch( - /createAndWork[\s\S]{0,200}MCP_TOKEN_CLEANUP_QUEUE[\s\S]{0,200}handleMcpTokenCleanup/, - ); + it("binds the cleanup handler", async () => { + expect(await loadedHandler(QUEUE)).toBe(handleMcpTokenCleanup); }); it("prunes MCP tokens inside the handler", () => { - expect(workerSource).toMatch( + expect(handlerSource).toMatch( /handleMcpTokenCleanup[\s\S]{0,400}cleanupExpiredMcpTokens/, ); }); diff --git a/src/lib/jobs/__tests__/mean-consolidation-queue.test.ts b/src/lib/jobs/__tests__/mean-consolidation-queue.test.ts index 4dbfd6d11..a2076591a 100644 --- a/src/lib/jobs/__tests__/mean-consolidation-queue.test.ts +++ b/src/lib/jobs/__tests__/mean-consolidation-queue.test.ts @@ -1,48 +1,44 @@ /** - * v1.7.0 — daily-mean consolidation queue registration guard. + * Daily-mean consolidation queue registration guard. * - * Same source-text-grep approach as the drain + step-consolidation - * regression guards: assert the queue is registered in `allQueues`, a - * `createAndWork` handler is wired against it, and the boot-discovery - * enqueue helper is called — without booting pg-boss + Prisma. An - * unregistered queue silently never drains. + * Reads the worker registry: the queue is declared in the rollup group, + * bound to the consolidation handler, and offered again by the rollup boot + * discovery. An unregistered queue silently never drains. */ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - import { describe, expect, it } from "vitest"; -// v1.18.1 — the mean-consolidation wiring + boot discovery moved out of the -// 2143-LOC reminder-worker boot file into the rollup registrar. The dead-queue -// guard follows the wiring there. -const REGISTRAR_PATH = join(__dirname, "..", "reminder", "register-rollup.ts"); -const source = readFileSync(REGISTRAR_PATH, "utf8"); +import { + MEAN_CONSOLIDATION_CONCURRENCY, + MEAN_CONSOLIDATION_QUEUE, +} from "@/lib/jobs/mean-consolidation"; +import { handleMeanConsolidation } from "@/lib/jobs/reminder/rollup-handlers"; +import { + discoveryOf, + groupOf, + jobOf, + loadedHandler, +} from "@/lib/jobs/registry/__tests__/registry-helpers"; -describe("reminder-worker — mean-consolidation wiring", () => { - it("imports the queue symbols from the mean-consolidation module", () => { - expect(source).toMatch(/from\s*["']@\/lib\/jobs\/mean-consolidation["']/); - expect(source).toMatch(/\bMEAN_CONSOLIDATION_QUEUE\b/); - expect(source).toMatch(/\brunMeanConsolidationForUser\b/); - expect(source).toMatch(/\benqueueBootTimeMeanConsolidation\b/); +describe("mean-consolidation registration", () => { + it("declares the mean-consolidation queue in the rollup group", () => { + expect(groupOf(MEAN_CONSOLIDATION_QUEUE).name).toBe("rollup"); }); - it("registers the mean-consolidation queue in the allQueues loop", () => { - const allQueuesMatch = source.match( - /const allQueues\s*=\s*\[([\s\S]*?)\];/, - ); - expect(allQueuesMatch).not.toBeNull(); - expect(allQueuesMatch![1]).toMatch(/\bMEAN_CONSOLIDATION_QUEUE\b/); + it("is a per-user exclusive pass at the module's concurrency", () => { + const job = jobOf(MEAN_CONSOLIDATION_QUEUE); + expect(job.policy?.policy).toBe("exclusive"); + expect(job.work).toEqual({ + localConcurrency: MEAN_CONSOLIDATION_CONCURRENCY, + }); }); - it("registers a createAndWork handler against the mean-consolidation queue", () => { - expect(source).toMatch( - /createAndWork[\s\S]{0,200}MEAN_CONSOLIDATION_QUEUE[\s\S]{0,400}runMeanConsolidationForUser/, + it("binds the consolidation handler", async () => { + expect(await loadedHandler(MEAN_CONSOLIDATION_QUEUE)).toBe( + handleMeanConsolidation, ); }); - it("fires the boot-discovery enqueue helper", () => { - // The boot wiring passes a stagger offset so the heavy backfills don't all - // drain in parallel onto one tenant; allow an optional argument here. - expect(source).toMatch(/await enqueueBootTimeMeanConsolidation\([^)]*\)/); + it("runs the boot discovery pass", () => { + expect(discoveryOf("rollup")).toContain("mean-consolidation"); }); }); diff --git a/src/lib/jobs/__tests__/measurement-reminder-queue.test.ts b/src/lib/jobs/__tests__/measurement-reminder-queue.test.ts index 564b9cb95..98f87af95 100644 --- a/src/lib/jobs/__tests__/measurement-reminder-queue.test.ts +++ b/src/lib/jobs/__tests__/measurement-reminder-queue.test.ts @@ -1,90 +1,81 @@ /** - * v1.17.1 — measurement-reminder queue registration guard. + * Measurement-reminder queue registration guard. * - * Same source-text-grep approach as the cycle / mood / drain registration - * guards: assert the Vorsorge-reminder queue is declared, listed in - * `allQueues` (the v1.4.37 dead-queue lesson — an unregistered queue is - * never provisioned and the schedule silently no-ops), scheduled on its - * cron, and wired to a `createAndWork` handler that delegates to - * `runMeasurementReminderTick`. No pg-boss / Prisma boot. + * Reads the worker registry: the Vorsorge-reminder queue is declared in the + * reminders group (an unregistered queue is never provisioned and the + * schedule silently no-ops), scheduled on its cron, and bound to the handler + * that delegates to `runMeasurementReminderTick`; the eventful + * reminder-satisfy queue sits beside it. No pg-boss / Prisma boot. */ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -// v1.18.1 — the reminder-dispatch wiring (measurement reminder + -// reminder-satisfy) moved out of the 2143-LOC reminder-worker boot file into -// the reminders registrar. The dead-queue guard follows the wiring there (the -// handler module is still concatenated for the handler-symbol assertions). -const source = - readFileSync( - join(__dirname, "..", "reminder", "register-reminders.ts"), - "utf8", - ) + - readFileSync( - join(__dirname, "..", "reminder", "mood-cycle-checks.ts"), - "utf8", - ); +import { + REMINDER_SATISFY_CONCURRENCY, + REMINDER_SATISFY_QUEUE, +} from "@/lib/jobs/reminder-satisfy"; +import { + handleMeasurementReminderCheck, + handleReminderSatisfy, +} from "@/lib/jobs/reminder/mood-cycle-checks"; +import { + groupOf, + jobOf, + loadedHandler, +} from "@/lib/jobs/registry/__tests__/registry-helpers"; -describe("reminder-worker — measurement-reminder wiring", () => { +const MEASUREMENT_REMINDER_QUEUE = "measurement-reminder-check"; + +const handlerSource = readFileSync( + join(__dirname, "..", "reminder", "mood-cycle-checks.ts"), + "utf8", +); + +describe("measurement-reminder registration", () => { it("imports the measurement-reminder tick runner", () => { - expect(source).toMatch(/from\s*["']@\/lib\/jobs\/measurement-reminder["']/); - expect(source).toMatch(/\brunMeasurementReminderTick\b/); + expect(handlerSource).toMatch( + /from\s*["']@\/lib\/jobs\/measurement-reminder["']/, + ); + expect(handlerSource).toMatch(/\brunMeasurementReminderTick\b/); }); - it("declares the queue + cron constants", () => { - expect(source).toMatch( - /MEASUREMENT_REMINDER_QUEUE\s*=\s*["']measurement-reminder-check["']/, - ); - expect(source).toMatch( - /MEASUREMENT_REMINDER_CRON\s*=\s*["']\*\/15 \* \* \* \*["']/, - ); + it("declares the queue in the reminders group", () => { + expect(groupOf(MEASUREMENT_REMINDER_QUEUE).name).toBe("reminders"); }); - it("registers the queue in the allQueues loop", () => { - const allQueuesMatch = source.match( - /const allQueues\s*=\s*\[([\s\S]*?)\];/, - ); - expect(allQueuesMatch).not.toBeNull(); - expect(allQueuesMatch![1]).toMatch(/\bMEASUREMENT_REMINDER_QUEUE\b/); + it("schedules the measurement-reminder cron, single-flight", () => { + const job = jobOf(MEASUREMENT_REMINDER_QUEUE); + expect(job.cron).toBe("*/15 * * * *"); + expect(job.work).toEqual({ localConcurrency: 1 }); }); - it("schedules the measurement-reminder cron", () => { - expect(source).toMatch( - /\[MEASUREMENT_REMINDER_QUEUE,\s*MEASUREMENT_REMINDER_CRON\]/, + it("binds the handler that runs the tick", async () => { + expect(await loadedHandler(MEASUREMENT_REMINDER_QUEUE)).toBe( + handleMeasurementReminderCheck, ); - }); - - it("registers a createAndWork handler that runs the tick", () => { - expect(source).toMatch( - /createAndWork[\s\S]{0,200}MEASUREMENT_REMINDER_QUEUE[\s\S]{0,200}handleMeasurementReminderCheck/, - ); - expect(source).toMatch( + expect(handlerSource).toMatch( /handleMeasurementReminderCheck[\s\S]{0,400}runMeasurementReminderTick/, ); }); }); -describe("reminder-worker — eventful reminder-satisfy wiring (v1.18.1)", () => { - it("imports the satisfy queue constants", () => { - expect(source).toMatch(/from\s*["']@\/lib\/jobs\/reminder-satisfy["']/); - expect(source).toMatch(/\bREMINDER_SATISFY_QUEUE\b/); +describe("eventful reminder-satisfy registration", () => { + it("declares the satisfy queue in the reminders group, with no cron", () => { + expect(groupOf(REMINDER_SATISFY_QUEUE).name).toBe("reminders"); + const job = jobOf(REMINDER_SATISFY_QUEUE); + expect(job.cron).toBeUndefined(); + expect(job.work).toEqual({ + localConcurrency: REMINDER_SATISFY_CONCURRENCY, + }); }); - it("registers the satisfy queue in the allQueues loop", () => { - const allQueuesMatch = source.match( - /const allQueues\s*=\s*\[([\s\S]*?)\];/, + it("binds the satisfy handler", async () => { + expect(await loadedHandler(REMINDER_SATISFY_QUEUE)).toBe( + handleReminderSatisfy, ); - expect(allQueuesMatch).not.toBeNull(); - expect(allQueuesMatch![1]).toMatch(/\bREMINDER_SATISFY_QUEUE\b/); - }); - - it("binds a createAndWork handler for the satisfy queue", () => { - expect(source).toMatch( - /createAndWork[\s\S]{0,200}REMINDER_SATISFY_QUEUE[\s\S]{0,200}handleReminderSatisfy/, - ); - expect(source).toMatch( + expect(handlerSource).toMatch( /handleReminderSatisfy[\s\S]{0,800}runReminderSatisfyForUser/, ); }); diff --git a/src/lib/jobs/__tests__/measurement-tombstone-cleanup.test.ts b/src/lib/jobs/__tests__/measurement-tombstone-cleanup.test.ts index f27b28739..9defa2f68 100644 --- a/src/lib/jobs/__tests__/measurement-tombstone-cleanup.test.ts +++ b/src/lib/jobs/__tests__/measurement-tombstone-cleanup.test.ts @@ -1,23 +1,26 @@ /** - * v1.7.0 — measurement-tombstone retention cleanup. + * Measurement-tombstone retention cleanup. * * Two guards: * 1. Behavioural: the helper hard-deletes only soft-deleted rows whose * `deletedAt` predates the retention horizon, leaving live rows and * recently-tombstoned rows untouched. - * 2. Source-grep wiring (same approach as the drain-cumulative guard): - * the queue is registered in `allQueues`, scheduled, and bound to a - * `createAndWork` handler — a missing `allQueues` entry silently no-ops - * the schedule under pg-boss v12. + * 2. Registry wiring: the queue is declared in the maintenance group, + * scheduled, and bound to its handler — a queue the registry does not + * declare is never created, and its schedule silently no-ops under + * pg-boss v12. */ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - import { describe, expect, it, vi } from "vitest"; import { cleanupExpiredMeasurementTombstones } from "../measurement-tombstone-cleanup"; import { PURGE_BATCH_SIZE, PURGE_MAX_BATCHES } from "../purge-batch"; import { TOMBSTONE_RETENTION_DAYS } from "@/lib/auth/native-client"; +import { handleMeasurementTombstoneCleanup } from "../reminder/cleanup-handlers"; +import { + groupOf, + jobOf, + loadedHandler, +} from "../registry/__tests__/registry-helpers"; const DAY_MS = 86_400_000; @@ -170,42 +173,24 @@ describe("cleanupExpiredMeasurementTombstones — accounts under restore", () => }); }); -describe("reminder-worker — measurement-tombstone-cleanup schedule", () => { - // v1.18.1 — the cleanup wiring moved out of the 2143-LOC reminder-worker - // boot file into the maintenance registrar. The dead-queue guard follows it. - const source = readFileSync( - join(__dirname, "..", "reminder", "register-maintenance.ts"), - "utf8", - ); +describe("measurement-tombstone-cleanup schedule", () => { + const QUEUE = "measurement-tombstone-cleanup"; it("declares the queue at the documented Berlin cadence", () => { - expect(source).toMatch( - /MEASUREMENT_TOMBSTONE_CLEANUP_QUEUE\s*=\s*["']measurement-tombstone-cleanup["']/, - ); - expect(source).toMatch( - /MEASUREMENT_TOMBSTONE_CLEANUP_CRON\s*=\s*["']40 3 \* \* \*["']/, - ); + expect(jobOf(QUEUE).cron).toBe("40 3 * * *"); }); - it("registers the queue in the allQueues createQueue loop", () => { - const allQueuesMatch = source.match( - /const allQueues\s*=\s*\[([\s\S]*?)\];/, - ); - expect(allQueuesMatch).not.toBeNull(); - expect(allQueuesMatch![1]).toMatch( - /\bMEASUREMENT_TOMBSTONE_CLEANUP_QUEUE\b/, - ); + it("declares the queue in the maintenance group", () => { + expect(groupOf(QUEUE).name).toBe("maintenance"); }); - it("schedules the cron via boss.schedule (allQueues + schedules)", () => { - expect(source).toMatch( - /\[MEASUREMENT_TOMBSTONE_CLEANUP_QUEUE,\s*MEASUREMENT_TOMBSTONE_CLEANUP_CRON\]/, - ); + it("keeps the default retries on the schedule", () => { + const job = jobOf(QUEUE); + expect(job.scheduleOptions).toBeUndefined(); + expect(job.work).toEqual({ localConcurrency: 1 }); }); - it("binds a createAndWork handler to the queue", () => { - expect(source).toMatch( - /createAndWork[\s\S]{0,200}MEASUREMENT_TOMBSTONE_CLEANUP_QUEUE[\s\S]{0,200}handleMeasurementTombstoneCleanup/, - ); + it("binds the cleanup handler to the queue", async () => { + expect(await loadedHandler(QUEUE)).toBe(handleMeasurementTombstoneCleanup); }); }); diff --git a/src/lib/jobs/__tests__/mood-prognosis-refresh.test.ts b/src/lib/jobs/__tests__/mood-prognosis-refresh.test.ts index b3c77861d..8f6e7bd6a 100644 --- a/src/lib/jobs/__tests__/mood-prognosis-refresh.test.ts +++ b/src/lib/jobs/__tests__/mood-prognosis-refresh.test.ts @@ -46,11 +46,13 @@ const predictionFindManyMock = vi.fn(); const deleteManyMock = vi.fn(); import { - MOOD_PROGNOSIS_REFRESH_CRON, - MOOD_PROGNOSIS_REFRESH_QUEUE, handleMoodPrognosisRefresh, runMoodPrognosisRefresh, } from "@/lib/jobs/mood-prognosis-refresh"; +import { + MOOD_PROGNOSIS_REFRESH_CRON, + MOOD_PROGNOSIS_REFRESH_QUEUE, +} from "@/lib/jobs/mood-prognosis-refresh-queue"; import { MIN_ENTRIES_FOR_FORECAST } from "@/lib/analytics/mood-prognosis/thresholds"; const NOW = new Date("2026-08-08T02:35:00.000Z"); diff --git a/src/lib/jobs/__tests__/nightscout-queues.test.ts b/src/lib/jobs/__tests__/nightscout-queues.test.ts index 6c994d55a..fa2cf8173 100644 --- a/src/lib/jobs/__tests__/nightscout-queues.test.ts +++ b/src/lib/jobs/__tests__/nightscout-queues.test.ts @@ -1,96 +1,78 @@ /** - * v1.17.0 — pg-boss queue registration for the three new poll-only sync layers: - * Nightscout CGM, Polar, and Oura. + * pg-boss queue registration for the three poll-only sync layers: Nightscout + * CGM, Polar, and Oura. * - * Source-as-text guard (the reminder-worker pulls heavy infra we don't boot - * here): assert each queue constant + cron + handler wiring + allQueues - * membership are present — the fast guard against the v1.4.37 dead-queue class - * (a queue declared but never registered in `allQueues`, which silently never - * drains). All three syncs are poll-only (no webhook, no backfill queue): a - * single hourly tick walks every configured account. + * Reads the worker registry (the worker pulls heavy infra we don't boot + * here): each queue is declared in the integration-sync group with its cron + * and handler — the fast guard against the dead-queue class (a queue declared + * but never registered, which silently never drains). All three syncs are + * poll-only (no webhook): a single hourly tick walks every configured + * account. The queue names live in `integration-poll-queues.ts`, so an OAuth + * callback can enqueue a connect-time first sync without the handler graph. */ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - import { describe, expect, it } from "vitest"; -// v1.18.1 — the queue wiring moved out of the 2143-LOC reminder-worker boot -// file into domain registrars; the Nightscout / Polar / Oura queues live in the -// integration-sync registrar. The dead-queue guard follows the wiring there -// (the handler module is still concatenated for the handler-symbol assertions). -// -// v1.32.28 — the four poll-queue NAMES moved again, into -// `integration-poll-queues.ts`, so an OAuth callback can enqueue a connect-time -// first sync without importing the registrar's handler graph. The guard follows -// them: the name declarations are asserted against that module, everything else -// (allQueues membership, cron schedule, handler binding) still against the -// registrar. -const source = - readFileSync(join(__dirname, "..", "integration-poll-queues.ts"), "utf8") + - readFileSync( - join(__dirname, "..", "reminder", "register-integration-sync.ts"), - "utf8", - ) + - readFileSync(join(__dirname, "..", "reminder", "nightscout-sync.ts"), "utf8"); +import { + NIGHTSCOUT_SYNC_CRON, + NIGHTSCOUT_SYNC_QUEUE, + OURA_SYNC_CRON, + OURA_SYNC_QUEUE, + POLAR_SYNC_CRON, + POLAR_SYNC_QUEUE, +} from "@/lib/jobs/integration-poll-queues"; +import { handleNightscoutSync } from "@/lib/jobs/reminder/nightscout-sync"; +import { handleOuraSync } from "@/lib/jobs/reminder/oura-sync"; +import { handlePolarSync } from "@/lib/jobs/reminder/polar-sync"; +import { + groupOf, + jobOf, + loadedHandler, +} from "@/lib/jobs/registry/__tests__/registry-helpers"; -describe("reminder-worker — Nightscout sync queue", () => { +describe("Nightscout sync queue", () => { it("declares the nightscout-sync queue constant", () => { - expect(source).toMatch( - /NIGHTSCOUT_SYNC_QUEUE\s*=\s*["']nightscout-sync["']/, - ); + expect(NIGHTSCOUT_SYNC_QUEUE).toBe("nightscout-sync"); }); - it("registers the queue in allQueues (v1.4.37 dead-queue guard)", () => { - const block = /const allQueues = \[([\s\S]*?)\];/.exec(source); - expect(block).not.toBeNull(); - expect(block![1]!).toContain("NIGHTSCOUT_SYNC_QUEUE"); + it("declares the queue in the integration-sync group", () => { + expect(groupOf(NIGHTSCOUT_SYNC_QUEUE).name).toBe("integration_sync"); }); it("schedules the hourly poll cron", () => { - expect(source).toMatch(/\[NIGHTSCOUT_SYNC_QUEUE,\s*NIGHTSCOUT_SYNC_CRON\]/); + expect(jobOf(NIGHTSCOUT_SYNC_QUEUE).cron).toBe(NIGHTSCOUT_SYNC_CRON); }); - it("registers a createAndWork handler for the queue", () => { - expect(source).toMatch( - /createAndWork[\s\S]{0,160}NIGHTSCOUT_SYNC_QUEUE[\s\S]{0,160}handleNightscoutSync/, + it("binds the handler for the queue", async () => { + expect(await loadedHandler(NIGHTSCOUT_SYNC_QUEUE)).toBe( + handleNightscoutSync, ); }); }); -describe("reminder-worker — Polar / Oura sync queues", () => { - // The same v1.4.37 dead-queue class applies to the two OAuth-wearable polls - // shipped alongside Nightscout in v1.17.0 — assert their allQueues membership - // so a future refactor can't silently drop one and starve the sync. - const allQueuesBlock = () => { - const block = /const allQueues = \[([\s\S]*?)\];/.exec(source); - expect(block).not.toBeNull(); - return block![1]!; - }; - +describe("Polar / Oura sync queues", () => { + // The same dead-queue class applies to the two OAuth-wearable polls shipped + // alongside Nightscout — assert their registry membership so a future + // refactor can't silently drop one and starve the sync. it("declares the polar-sync and oura-sync queue constants", () => { - expect(source).toMatch(/POLAR_SYNC_QUEUE\s*=\s*["']polar-sync["']/); - expect(source).toMatch(/OURA_SYNC_QUEUE\s*=\s*["']oura-sync["']/); + expect(POLAR_SYNC_QUEUE).toBe("polar-sync"); + expect(OURA_SYNC_QUEUE).toBe("oura-sync"); }); - it("registers POLAR_SYNC_QUEUE in allQueues (v1.4.37 dead-queue guard)", () => { - expect(allQueuesBlock()).toContain("POLAR_SYNC_QUEUE"); + it("declares POLAR_SYNC_QUEUE in the integration-sync group", () => { + expect(groupOf(POLAR_SYNC_QUEUE).name).toBe("integration_sync"); }); - it("registers OURA_SYNC_QUEUE in allQueues (v1.4.37 dead-queue guard)", () => { - expect(allQueuesBlock()).toContain("OURA_SYNC_QUEUE"); + it("declares OURA_SYNC_QUEUE in the integration-sync group", () => { + expect(groupOf(OURA_SYNC_QUEUE).name).toBe("integration_sync"); }); it("schedules the hourly poll cron for both", () => { - expect(source).toMatch(/\[POLAR_SYNC_QUEUE,\s*POLAR_SYNC_CRON\]/); - expect(source).toMatch(/\[OURA_SYNC_QUEUE,\s*OURA_SYNC_CRON\]/); + expect(jobOf(POLAR_SYNC_QUEUE).cron).toBe(POLAR_SYNC_CRON); + expect(jobOf(OURA_SYNC_QUEUE).cron).toBe(OURA_SYNC_CRON); }); - it("registers a createAndWork handler for each queue", () => { - expect(source).toMatch( - /createAndWork[\s\S]{0,160}POLAR_SYNC_QUEUE[\s\S]{0,160}handlePolarSync/, - ); - expect(source).toMatch( - /createAndWork[\s\S]{0,160}OURA_SYNC_QUEUE[\s\S]{0,160}handleOuraSync/, - ); + it("binds a handler for each queue", async () => { + expect(await loadedHandler(POLAR_SYNC_QUEUE)).toBe(handlePolarSync); + expect(await loadedHandler(OURA_SYNC_QUEUE)).toBe(handleOuraSync); }); }); diff --git a/src/lib/jobs/__tests__/period-narrative-warm.test.ts b/src/lib/jobs/__tests__/period-narrative-warm.test.ts index a41071ad5..8bdf1bb09 100644 --- a/src/lib/jobs/__tests__/period-narrative-warm.test.ts +++ b/src/lib/jobs/__tests__/period-narrative-warm.test.ts @@ -1,6 +1,4 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import fs from "node:fs"; -import path from "node:path"; const checkRateLimit = vi.fn(); const getAssistantFlags = vi.fn(); @@ -34,6 +32,14 @@ import { PERIOD_NARRATIVE_QUEUE, PERIOD_NARRATIVE_CRON, } from "../period-narrative-warm"; +import { PERIOD_NARRATIVE_EXPIRE_SECONDS } from "../period-narrative-queue"; +import { handlePeriodNarrative } from "../reminder/status-handlers"; +import { insightRetryOptions } from "../reminder/registrar-shared"; +import { + groupOf, + jobOf, + loadedHandler, +} from "../registry/__tests__/registry-helpers"; function makePrisma( users: Array<{ id: string; locale: string | null }>, @@ -234,29 +240,22 @@ describe("runPeriodNarrativeWarm", () => { }); describe("queue registration", () => { - // v1.18.1 — the period-narrative wiring moved out of the 2143-LOC - // reminder-worker boot file into the status registrar. The dead-queue guard - // follows the wiring there. - const workerSrc = fs.readFileSync( - path.resolve(__dirname, "../reminder/register-status.ts"), - "utf8", - ); - - it("registers the queue in the allQueues createQueue loop", () => { - const match = workerSrc.match(/const allQueues\s*=\s*\[([\s\S]*?)\];/); - expect(match).not.toBeNull(); - expect(match![1]).toMatch(/\bPERIOD_NARRATIVE_QUEUE\b/); + it("declares the queue in the status group", () => { + expect(groupOf(PERIOD_NARRATIVE_QUEUE).name).toBe("status"); }); - it("schedules the cron in the schedules table (with retry policy)", () => { - expect(workerSrc).toMatch( - /\[\s*PERIOD_NARRATIVE_QUEUE\s*,\s*PERIOD_NARRATIVE_CRON\s*,\s*\{\s*\.\.\.insightRetryOptions\s*,\s*expireInSeconds:\s*PERIOD_NARRATIVE_EXPIRE_SECONDS\s*,?\s*\}\s*,?\s*\]/, - ); + it("schedules the cron with the retry policy", () => { + const job = jobOf(PERIOD_NARRATIVE_QUEUE); + expect(job.cron).toBe(PERIOD_NARRATIVE_CRON); + expect(job.scheduleOptions).toEqual({ + ...insightRetryOptions, + expireInSeconds: PERIOD_NARRATIVE_EXPIRE_SECONDS, + }); }); - it("registers a createAndWork handler for the queue", () => { - expect(workerSrc).toMatch( - /createAndWork[\s\S]{0,120}PERIOD_NARRATIVE_QUEUE/, + it("binds the warm handler to the queue", async () => { + expect(await loadedHandler(PERIOD_NARRATIVE_QUEUE)).toBe( + handlePeriodNarrative, ); }); diff --git a/src/lib/jobs/__tests__/queue-policy-tables.test.ts b/src/lib/jobs/__tests__/queue-policy-tables.test.ts index bc4b5ea04..7a728b6fb 100644 --- a/src/lib/jobs/__tests__/queue-policy-tables.test.ts +++ b/src/lib/jobs/__tests__/queue-policy-tables.test.ts @@ -1,160 +1,138 @@ /** - * Dead-policy guard, in the same spirit as the v1.4.37 dead-queue guards. + * Dead-policy guard, in the same spirit as the dead-queue guards. * - * A queue's `singletonKey` only de-duplicates if its registrar names a policy - * for it: pg-boss constrains `singleton_key` through partial unique indexes - * scoped by queue policy, and the default `standard` policy has none. So the - * enqueue site and the registrar entry are two halves of one guarantee that - * live in different files, and nothing in the type system couples them — - * deleting the registrar entry leaves the `singletonKey` at the call site - * looking entirely correct while it silently stops doing anything. + * A queue's `singletonKey` only de-duplicates if its registry declaration + * names a policy for it: pg-boss constrains `singleton_key` through partial + * unique indexes scoped by queue policy, and the default `standard` policy + * has none. So the enqueue site and the declaration are two halves of one + * guarantee that live in different files, and nothing in the type system + * couples them — dropping the declaration's policy leaves the `singletonKey` + * at the call site looking entirely correct while it silently stops doing + * anything. * - * These assertions are that coupling. Each queue below is listed with the - * policy its call site depends on; dropping or weakening an entry turns this - * suite red instead of quietly restoring the storm. + * These assertions are that coupling. Each queue below is listed by its wire + * name with the policy its call site depends on, and must be declared in the + * named registry group; dropping or weakening an entry turns this suite red + * instead of quietly restoring the storm. * * The behavioural proof that the policies mean what the names say lives in * `tests/integration/queue-singleton-policy.integration.test.ts`, which runs a * real pg-boss against a real Postgres. */ -import fs from "node:fs"; -import path from "node:path"; - import { describe, expect, it } from "vitest"; -const read = (file: string) => - fs.readFileSync(path.resolve(__dirname, "../reminder", file), "utf8"); +import type { JobGroupName } from "@/lib/jobs/registry/define-job"; +import { + groupNamed, + groupOf, + jobOf, +} from "@/lib/jobs/registry/__tests__/registry-helpers"; -/** - * Assert that `queue` appears in the registrar's `queuePolicies` table with - * `policy`. Anchored to the table so an unrelated mention of the constant - * elsewhere in the file cannot satisfy it. - */ -function expectPolicy(source: string, queue: string, policy: string) { - const table = source.match( - /const queuePolicies: QueuePolicyTable = \{([\s\S]*?)\n\};/, - ); - expect(table, "registrar has no queuePolicies table").not.toBeNull(); - const entry = new RegExp(`\\[${queue}\\]:\\s*\\{\\s*policy:\\s*"${policy}"`); +/** Assert that `queue` is declared in `group` with `policy`. */ +function expectPolicy(group: JobGroupName, queue: string, policy: string) { + expect(groupOf(queue).name).toBe(group); expect( - table![1], + jobOf(queue).policy?.policy, `${queue} must carry the "${policy}" policy — without it the singletonKey at its enqueue site is inert`, - ).toMatch(entry); + ).toBe(policy); } -describe("rollup registrar queue policies", () => { - const source = read("register-rollup.ts"); - +describe("rollup group queue policies", () => { // `short`, not `exclusive`: the handler re-reads the bucket when it starts, // so only QUEUED duplicates may be collapsed. Promoting either of these to // `exclusive` would strand a measurement written while a recompute ran. it.each([ - ["ROLLUP_RECOMPUTE_QUEUE", "short"], - ["MOOD_ROLLUP_RECOMPUTE_QUEUE", "short"], + ["rollup-recompute", "short"], + ["mood-rollup-recompute", "short"], ])("%s is %s", (queue, policy) => { - expectPolicy(source, queue, policy); + expectPolicy("rollup", queue, policy); }); // Per-user, self-converging backfills: a second concurrent run is pure // duplicated work and discovery re-offers the job while it is outstanding. it.each([ - ["ROLLUP_FULL_BACKFILL_QUEUE", "exclusive"], - ["MOOD_ROLLUP_FULL_BACKFILL_QUEUE", "exclusive"], - ["STEP_CONSOLIDATION_QUEUE", "exclusive"], - ["STEP_CONSOLIDATION_REPAIR_QUEUE", "exclusive"], - ["CUMULATIVE_PR_REDERIVE_QUEUE", "exclusive"], - ["MEAN_CONSOLIDATION_QUEUE", "exclusive"], - ["DENSE_INTRADAY_RETENTION_QUEUE", "exclusive"], - ["DENSE_INTRADAY_HOURLY_REBUILD_QUEUE", "exclusive"], + ["rollup-full-backfill", "exclusive"], + ["mood-rollup-full-backfill", "exclusive"], + ["step-consolidation", "exclusive"], + ["step-consolidation-repair", "exclusive"], + ["cumulative-pr-rederive", "exclusive"], + ["mean-consolidation", "exclusive"], + ["dense-intraday-retention", "exclusive"], + ["dense-intraday-hourly-rebuild", "exclusive"], ])("%s is %s", (queue, policy) => { - expectPolicy(source, queue, policy); + expectPolicy("rollup", queue, policy); }); }); -describe("status registrar queue policies", () => { - const source = read("register-status.ts"); - - it("MORNING_DIGEST_REFRESH_QUEUE is exclusive", () => { +describe("status group queue policies", () => { + it("morning-digest-refresh is exclusive", () => { // Seven sleep-write seams enqueue the same user+localDate key. Without // this the at-most-once-per-morning contract does not hold and the failure // path re-triggers forced comprehensive generations against the provider. - expectPolicy(source, "MORNING_DIGEST_REFRESH_QUEUE", "exclusive"); + expectPolicy("status", "morning-digest-refresh", "exclusive"); }); }); -describe("integration-sync registrar queue policies", () => { - const source = read("register-integration-sync.ts"); - +describe("integration-sync group queue policies", () => { it.each([ - ["WHOOP_BACKFILL_QUEUE", "exclusive"], - ["FITBIT_BACKFILL_QUEUE", "exclusive"], - ["GOOGLE_HEALTH_BACKFILL_QUEUE", "exclusive"], - ["STRAVA_BACKFILL_QUEUE", "exclusive"], - ["OURA_BACKFILL_QUEUE", "exclusive"], - ["GOOGLE_HEALTH_SLEEP_REPAIR_QUEUE", "exclusive"], - ["FITBIT_SLEEP_REPAIR_QUEUE", "exclusive"], - ["SLEEP_TIMELINE_BACKFILL_QUEUE", "exclusive"], - ["LAB_BIOMARKER_BACKFILL_QUEUE", "exclusive"], + ["whoop-backfill", "exclusive"], + ["fitbit-backfill", "exclusive"], + ["google-health-backfill", "exclusive"], + ["strava-backfill", "exclusive"], + ["oura-backfill", "exclusive"], + ["google-health-sleep-repair", "exclusive"], + ["fitbit-sleep-repair", "exclusive"], + ["sleep-timeline-backfill", "exclusive"], + ["lab-biomarker-backfill", "exclusive"], ])("%s is %s", (queue, policy) => { - expectPolicy(source, queue, policy); + expectPolicy("integration_sync", queue, policy); }); }); -describe("maintenance registrar queue policies", () => { - const source = read("register-maintenance.ts"); - +describe("maintenance group queue policies", () => { it.each([ - ["INTAKE_SLOT_DEDUP_QUEUE", "exclusive"], - ["NOTE_ENCRYPTION_BACKFILL_QUEUE", "exclusive"], - ["MED_NOTES_ENCRYPTION_BACKFILL_QUEUE", "exclusive"], - ["FREE_TEXT_ENCRYPTION_BACKFILL_QUEUE", "exclusive"], - ["DOCUMENT_THUMBNAIL_BACKFILL_QUEUE", "exclusive"], - ["CONTENT_INDEX_BACKFILL_QUEUE", "exclusive"], - ["ENCRYPTION_KEY_ROTATE_QUEUE", "exclusive"], + ["intake-slot-dedup", "exclusive"], + ["note-encryption-backfill", "exclusive"], + ["med-notes-encryption-backfill", "exclusive"], + ["free-text-encryption-backfill", "exclusive"], + ["document-thumbnail-backfill", "exclusive"], + ["document-content-index-backfill", "exclusive"], + ["encryption-key-rotate", "exclusive"], ])("%s is %s", (queue, policy) => { - expectPolicy(source, queue, policy); + expectPolicy("maintenance", queue, policy); }); // Per-document queues have no discovery pass to re-converge, so a re-process // requested after the running job read the old bytes must be admitted. it.each([ - ["DOCUMENT_INDEX_QUEUE", "short"], - ["DOCUMENT_THUMBNAIL_QUEUE", "short"], - ["DOCUMENT_SUMMARY_QUEUE", "short"], + ["document-index", "short"], + ["document-thumbnail", "short"], + ["document-summary", "short"], ])("%s is %s", (queue, policy) => { - expectPolicy(source, queue, policy); + expectPolicy("maintenance", queue, policy); }); - it("ENVIRONMENT_FETCH_QUEUE is deliberately left on standard", () => { + it("environment-fetch is deliberately left on standard", () => { // Its explicit-range backfill sends with no key at all, on purpose, so any // policy would collapse two different requested date ranges onto the shared - // empty key. This asserts the omission is intentional and documented, so a - // future reader does not "complete" the table and silently merge them. - const table = source.match( - /const queuePolicies: QueuePolicyTable = \{([\s\S]*?)\n\};/, - ); - expect(table![1]).not.toMatch(/\[ENVIRONMENT_FETCH_QUEUE\]/); - expect(source).toMatch(/ENVIRONMENT_FETCH_QUEUE is LEFT ALONE/); + // empty key. A future reader must not "complete" the declaration and + // silently merge them. + expect(groupOf("environment-fetch").name).toBe("maintenance"); + expect(jobOf("environment-fetch").policy).toBeUndefined(); }); }); describe("every policy decision carries a reason", () => { - it.each([ - "register-rollup.ts", - "register-status.ts", - "register-integration-sync.ts", - "register-maintenance.ts", - ])("%s", (file) => { - const table = read(file).match( - /const queuePolicies: QueuePolicyTable = \{([\s\S]*?)\n\};/, - ); - expect(table).not.toBeNull(); - - const policies = table![1].match(/policy:\s*"(?:short|exclusive)"/g) ?? []; - const reasons = table![1].match(/reason:\s*\n?\s*"/g) ?? []; - expect(policies.length).toBeGreaterThan(0); - // A policy without a stated reason is the thing this whole change exists - // to prevent: the next person re-deriving intent from the call sites. - expect(reasons.length).toBe(policies.length); - }); + it.each(["rollup", "status", "integration_sync", "maintenance"] as const)( + "%s", + (name) => { + const decided = groupNamed(name).jobs.filter((job) => job.policy); + expect(decided.length).toBeGreaterThan(0); + // A policy without a stated reason is the thing this whole change exists + // to prevent: the next person re-deriving intent from the call sites. + for (const job of decided) { + expect(job.policy!.reason.trim(), job.queue).not.toBe(""); + } + }, + ); }); diff --git a/src/lib/jobs/__tests__/read-side-effect-queues.test.ts b/src/lib/jobs/__tests__/read-side-effect-queues.test.ts index 71c952909..d956f6d95 100644 --- a/src/lib/jobs/__tests__/read-side-effect-queues.test.ts +++ b/src/lib/jobs/__tests__/read-side-effect-queues.test.ts @@ -2,72 +2,65 @@ * Dead-queue guard for the two queues that took the writes off the read paths. * * Moving a write off a GET only works if the new owner actually runs. A queue - * that is not in `allQueues` is never provisioned, a schedule without a - * `createAndWork` binding drains into nothing, and either failure is silent: + * the worker registry does not declare is never provisioned, a schedule + * without a bound handler drains into nothing, and either failure is silent: * the cron fires, the job vanishes, and the only symptom is a forecast that * stops moving or an unlock date that is never pinned. Both look exactly like * "nothing to do". * - * Source-text assertions over the registrar, matching the sibling - * `*-queue.test.ts` guards. + * Reads the registry declarations, matching the sibling `*-queue.test.ts` + * guards. */ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - import { describe, expect, it } from "vitest"; +import { handleAchievementUnlockSweep } from "@/lib/jobs/achievement-unlock-sweep"; +import { + ACHIEVEMENT_UNLOCK_SWEEP_CRON, + ACHIEVEMENT_UNLOCK_SWEEP_QUEUE, +} from "@/lib/jobs/achievement-unlock-sweep-queue"; +import { + CYCLE_PREDICTION_REFRESH_CRON, + CYCLE_PREDICTION_REFRESH_QUEUE, + handleCyclePredictionRefresh, +} from "@/lib/jobs/cycle-prediction-refresh"; +import { + groupOf, + jobOf, + loadedHandler, +} from "@/lib/jobs/registry/__tests__/registry-helpers"; import { SUBSYSTEM_SURFACE } from "@/lib/jobs/subsystem-surface"; -const registrar = readFileSync( - join(__dirname, "..", "reminder", "register-maintenance.ts"), - "utf8", -); - -const allQueues = registrar.match(/const allQueues\s*=\s*\[([\s\S]*?)\n\];/); - const QUEUES = [ { label: "cycle-prediction-refresh", - binding: "CYCLE_PREDICTION_REFRESH_QUEUE", - cron: "CYCLE_PREDICTION_REFRESH_CRON", - handler: "handleCyclePredictionRefresh", + queue: CYCLE_PREDICTION_REFRESH_QUEUE, + cron: CYCLE_PREDICTION_REFRESH_CRON, + handler: handleCyclePredictionRefresh, name: "cycle-prediction-refresh", }, { label: "achievement-unlock-sweep", - binding: "ACHIEVEMENT_UNLOCK_SWEEP_QUEUE", - cron: "ACHIEVEMENT_UNLOCK_SWEEP_CRON", - handler: "handleAchievementUnlockSweep", + queue: ACHIEVEMENT_UNLOCK_SWEEP_QUEUE, + cron: ACHIEVEMENT_UNLOCK_SWEEP_CRON, + handler: handleAchievementUnlockSweep, name: "achievement-unlock-sweep", }, ]; describe("queues that own a write moved off a GET", () => { - it("extracted an allQueues list to assert against", () => { - // The regexes below all read from this capture; if it ever stops - // matching, every assertion in this file would pass on an empty string. - expect(allQueues).not.toBeNull(); - expect(allQueues![1].length).toBeGreaterThan(0); - }); - for (const queue of QUEUES) { describe(queue.label, () => { - it("is provisioned in the allQueues list", () => { - expect(allQueues![1]).toMatch(new RegExp(`\\b${queue.binding}\\b`)); + it("is declared in the maintenance group", () => { + expect(queue.queue).toBe(queue.name); + expect(groupOf(queue.queue).name).toBe("maintenance"); }); it("carries a cron schedule", () => { - expect(registrar).toMatch( - new RegExp(`\\[\\s*${queue.binding},\\s*${queue.cron}`), - ); + expect(jobOf(queue.queue).cron).toBe(queue.cron); }); - it("binds a createAndWork handler that drains it", () => { - expect(registrar).toMatch( - new RegExp( - `createAndWork[\\s\\S]{0,200}${queue.binding}[\\s\\S]{0,200}${queue.handler}`, - ), - ); + it("binds the handler that drains it", async () => { + expect(await loadedHandler(queue.queue)).toBe(queue.handler); }); it("declares a failure audience", () => { diff --git a/src/lib/jobs/__tests__/recovery-score-queue.test.ts b/src/lib/jobs/__tests__/recovery-score-queue.test.ts index 65628b9e9..d97c088ff 100644 --- a/src/lib/jobs/__tests__/recovery-score-queue.test.ts +++ b/src/lib/jobs/__tests__/recovery-score-queue.test.ts @@ -1,10 +1,10 @@ /** - * v1.10.0 — computed scores (WX-C). Recovery-score queue registration guard. + * Computed scores: recovery-score queue registration guard. * - * Same source-text-grep approach as the other queue-wiring guards: assert - * the queue is imported, registered in `allQueues`, scheduled, and wired to - * a `createAndWork` handler — without booting pg-boss + Prisma. An unregistered - * queue silently never drains (the recurring past bug this guards against). + * Reads the worker registry: the queue is declared in the status group, + * scheduled on its cron, and bound to the handler that runs the pass. An + * unregistered queue silently never drains (the recurring past bug this + * guards against). * * Also pins the iOS-write rejection of the server-owned COMPUTED source on * the batch ingest route: the `batchSourceEnum` allowlist must stay exactly @@ -16,11 +16,16 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -// v1.18.1 — the queue wiring moved out of the 2143-LOC reminder-worker boot -// file into domain registrars; the recovery-score queue lives in the status -// registrar. The dead-queue guard follows the wiring there. -const REGISTRAR_PATH = join(__dirname, "..", "reminder", "register-status.ts"); -const workerSource = readFileSync(REGISTRAR_PATH, "utf8"); +import { + RECOVERY_SCORE_CRON, + RECOVERY_SCORE_QUEUE, +} from "@/lib/jobs/recovery-score-queue"; +import { handleRecoveryScore } from "@/lib/jobs/reminder/status-handlers"; +import { + groupOf, + jobOf, + loadedHandler, +} from "@/lib/jobs/registry/__tests__/registry-helpers"; const BATCH_ROUTE_PATH = join( __dirname, @@ -35,32 +40,19 @@ const BATCH_ROUTE_PATH = join( ); const batchSource = readFileSync(BATCH_ROUTE_PATH, "utf8"); -describe("reminder-worker — recovery-score wiring", () => { - it("imports the queue symbols from the recovery-score module", () => { - expect(workerSource).toMatch(/from\s*["']@\/lib\/jobs\/recovery-score["']/); - expect(workerSource).toMatch(/\bRECOVERY_SCORE_QUEUE\b/); - expect(workerSource).toMatch(/\bRECOVERY_SCORE_CRON\b/); - expect(workerSource).toMatch(/\brunRecoveryScore\b/); - }); - - it("registers the recovery-score queue in the allQueues loop", () => { - const allQueuesMatch = workerSource.match( - /const allQueues\s*=\s*\[([\s\S]*?)\];/, - ); - expect(allQueuesMatch).not.toBeNull(); - expect(allQueuesMatch![1]).toMatch(/\bRECOVERY_SCORE_QUEUE\b/); +describe("recovery-score registration", () => { + it("declares the recovery-score queue in the status group", () => { + expect(groupOf(RECOVERY_SCORE_QUEUE).name).toBe("status"); }); it("schedules the recovery-score cron", () => { - expect(workerSource).toMatch( - /\[RECOVERY_SCORE_QUEUE,\s*RECOVERY_SCORE_CRON\]/, - ); + const job = jobOf(RECOVERY_SCORE_QUEUE); + expect(job.cron).toBe(RECOVERY_SCORE_CRON); + expect(job.work).toEqual({ localConcurrency: 1 }); }); - it("registers a createAndWork handler that runs the recovery-score pass", () => { - expect(workerSource).toMatch( - /createAndWork[\s\S]{0,200}RECOVERY_SCORE_QUEUE[\s\S]{0,400}runRecoveryScore/, - ); + it("binds the handler that runs the recovery-score pass", async () => { + expect(await loadedHandler(RECOVERY_SCORE_QUEUE)).toBe(handleRecoveryScore); }); }); diff --git a/src/lib/jobs/__tests__/restore-drill.test.ts b/src/lib/jobs/__tests__/restore-drill.test.ts index 81eb035ef..07136af8d 100644 --- a/src/lib/jobs/__tests__/restore-drill.test.ts +++ b/src/lib/jobs/__tests__/restore-drill.test.ts @@ -2,13 +2,12 @@ import type { Readable } from "node:stream"; import { describe, it, expect, beforeEach, vi } from "vitest"; import { _resetCryptoCacheForTests, encrypt } from "@/lib/crypto"; import { encryptBackup } from "../offhost-backup"; +import { handleRestoreDrill, runRestoreDrill } from "../restore-drill"; import { - handleRestoreDrill, - runRestoreDrill, RESTORE_DRILL_CRON, RESTORE_DRILL_QUEUE, RESTORE_DRILL_SEND_OPTIONS, -} from "../restore-drill"; +} from "@/lib/jobs/restore-drill-queue"; import { QUEUE_RUNTIME } from "../queue-runtime"; vi.mock("../report-worker-error", () => ({ diff --git a/src/lib/jobs/__tests__/sleep-timeline-queues.test.ts b/src/lib/jobs/__tests__/sleep-timeline-queues.test.ts index cc803ecc5..f689739ec 100644 --- a/src/lib/jobs/__tests__/sleep-timeline-queues.test.ts +++ b/src/lib/jobs/__tests__/sleep-timeline-queues.test.ts @@ -1,50 +1,60 @@ /** - * v1.17.1 — pg-boss queue registration for the one-shot sleep-timeline backfill. + * pg-boss queue registration for the one-shot sleep-timeline backfill. * - * The reminder-worker imports heavy infrastructure, so we read the source as - * text and assert the queue constant is declared, registered in `allQueues`, - * wired to a `createAndWork` handler, and enqueued at boot — the fast guard that - * catches the v1.4.37 dead-queue class (a queue declared but never registered, - * which silently never drains). + * Reads the worker registry: the source queue is declared in the + * integration-sync group, forwards each job to the shared admission queue, + * and is offered again by the boot discovery. This is the fast guard for the + * dead-queue class (a queue declared but never registered, which silently + * never drains). */ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - import { describe, expect, it } from "vitest"; -// v1.18.1 — the queue wiring + boot discovery moved out of the 2143-LOC -// reminder-worker boot file into domain registrars; the sleep-timeline backfill -// lives in the integration-sync registrar. The dead-queue guard follows it. -const REGISTRAR_PATH = join( - __dirname, - "..", - "reminder", - "register-integration-sync.ts", -); -const source = readFileSync(REGISTRAR_PATH, "utf8"); +import { INTEGRATION_BACKFILL_ADMISSION_QUEUE } from "@/lib/jobs/integration-backfill-admission"; +import { + discoveryOf, + groupOf, + jobOf, + recordingBoss, +} from "@/lib/jobs/registry/__tests__/registry-helpers"; +import { + SLEEP_TIMELINE_BACKFILL_CONCURRENCY, + SLEEP_TIMELINE_BACKFILL_QUEUE, +} from "@/lib/jobs/sleep-timeline-backfill-queue"; -describe("reminder-worker — sleep-timeline backfill queue", () => { - it("registers the queue in allQueues (v1.4.37 dead-queue guard)", () => { - const block = /const allQueues = \[([\s\S]*?)\];/.exec(source); - expect(block).not.toBeNull(); - expect(block![1]!).toContain("SLEEP_TIMELINE_BACKFILL_QUEUE"); +describe("sleep-timeline backfill registration", () => { + it("declares the queue in the integration-sync group", () => { + expect(groupOf(SLEEP_TIMELINE_BACKFILL_QUEUE).name).toBe( + "integration_sync", + ); + const job = jobOf(SLEEP_TIMELINE_BACKFILL_QUEUE); + expect(job.policy?.policy).toBe("exclusive"); + expect(job.work).toEqual({ + localConcurrency: SLEEP_TIMELINE_BACKFILL_CONCURRENCY, + }); }); - it("wires a createAndWork handler through the shared admission queue", () => { - expect(source).toMatch( - /createAndWork[\s\S]{0,260}SLEEP_TIMELINE_BACKFILL_QUEUE[\s\S]{0,300}enqueueIntegrationBackfillAdmission/, - ); + it("forwards each job through the shared admission queue", async () => { + const { boss, sends } = recordingBoss(); + const handler = await jobOf(SLEEP_TIMELINE_BACKFILL_QUEUE).load({ boss }); + await handler([ + { + id: "j1", + name: SLEEP_TIMELINE_BACKFILL_QUEUE, + data: { userId: "u1", provider: "WHOOP" }, + expireInSeconds: 60, + heartbeatSeconds: null, + retryCount: 0, + signal: new AbortController().signal, + }, + ]); + expect(sends).toHaveLength(1); + expect(sends[0]!.queue).toBe(INTEGRATION_BACKFILL_ADMISSION_QUEUE); + expect(sends[0]!.data).toMatchObject({ kind: "sleep-timeline-backfill" }); }); it("wires the boot discovery", () => { - expect(source).toMatch( - /enqueueBootTimeSleepTimelineBackfill\(\s*bootStaggerSecondsFor\("sleep-timeline-backfill"\)/, - ); - }); - - it("imports the backfill exports", () => { - expect(source).toMatch( - /from\s*["']@\/lib\/jobs\/sleep-timeline-backfill["']/, + expect(discoveryOf("integration_sync")).toContain( + "sleep-timeline-backfill", ); }); }); diff --git a/src/lib/jobs/__tests__/status-cron-candidates.test.ts b/src/lib/jobs/__tests__/status-cron-candidates.test.ts index 331de5d62..b912a4c27 100644 --- a/src/lib/jobs/__tests__/status-cron-candidates.test.ts +++ b/src/lib/jobs/__tests__/status-cron-candidates.test.ts @@ -1,5 +1,5 @@ /** - * v1.15.20 — unit tests for the shared 02:xx status-cron discovery. + * Unit tests for the shared 02:xx status-cron discovery. * * Pins the three gates the discovery applies: * 1. the operator assistant kill-switch (`insightStatus` flag), @@ -9,8 +9,8 @@ * 3. the pregenerate-candidate skip (a stale or missing comprehensive * cache → the 04:30 pass owns the user; the 02:xx crons keep the * fresh-cache accounts), applied only while `briefing` is on, - * plus the mood-status queue registration in the worker source (the - * v1.4.37 dead-queue class guard). + * plus the nightly status batch's declaration in the worker registry (the + * dead-queue class guard). */ import { describe, it, expect, vi, beforeEach } from "vitest"; import fs from "node:fs"; @@ -25,6 +25,15 @@ vi.mock("@/lib/feature-flags", async () => import { findStatusCronCandidates } from "../status-cron-candidates"; import { PREGENERATE_STALE_MS } from "../insight-pregenerate"; +import { handleGeneralStatusGenerate } from "../reminder/insights-handlers"; +import { nightlyInsightPassOptions } from "../reminder/registrar-shared"; +import { allJobDefinitions } from "../registry"; +import { + groupNamed, + groupOf, + jobOf, + loadedHandler, +} from "../registry/__tests__/registry-helpers"; const NOW = new Date("2026-06-10T02:00:00.000Z"); const STALE_AT = new Date(NOW.getTime() - PREGENERATE_STALE_MS - 60_000); @@ -176,10 +185,7 @@ describe("findStatusCronCandidates — gates", () => { }); describe("the nightly status batch is the one status cron", () => { - const workerSrc = fs.readFileSync( - path.resolve(__dirname, "../reminder/register-status.ts"), - "utf8", - ); + const GENERAL_STATUS_QUEUE = "insights-general-status"; const RETIRED = [ "insights-blood-pressure-status", "insights-weight-status", @@ -189,27 +195,22 @@ describe("the nightly status batch is the one status cron", () => { "insights-medication-compliance-status", ]; - it("registers and schedules the 02:00 batch with the nightly pass options", () => { - const match = workerSrc.match(/const allQueues\s*=\s*\[([\s\S]*?)\];/); - expect(match).not.toBeNull(); - expect(match![1]).toMatch(/\bGENERAL_STATUS_QUEUE\b/); - expect(workerSrc).toMatch( - /\[\s*GENERAL_STATUS_QUEUE\s*,\s*GENERAL_STATUS_CRON\s*,\s*nightlyInsightPassOptions\s*\]/, + it("registers and schedules the 02:00 batch with the nightly pass options", async () => { + expect(groupOf(GENERAL_STATUS_QUEUE).name).toBe("status"); + const job = jobOf(GENERAL_STATUS_QUEUE); + expect(job.cron).toBe("0 2 * * *"); + expect(job.scheduleOptions).toBe(nightlyInsightPassOptions); + expect(await loadedHandler(GENERAL_STATUS_QUEUE)).toBe( + handleGeneralStatusGenerate, ); - expect(workerSrc).toMatch(/GENERAL_STATUS_CRON\s*=\s*"0 2 \* \* \*"/); }); it("retires the six per-metric crons on an instance that still has them", () => { - const retired = workerSrc.match( - /const RETIRED_STATUS_QUEUES\s*=\s*\[([\s\S]*?)\]/, + expect([...(groupNamed("status").retired ?? [])].sort()).toEqual( + [...RETIRED].sort(), ); - expect(retired).not.toBeNull(); - for (const name of RETIRED) expect(retired![1]).toContain(`"${name}"`); - expect(workerSrc).toMatch( - /await retireQueues\(boss,\s*RETIRED_STATUS_QUEUES\)/, - ); - const live = workerSrc.replace(retired![0], ""); - for (const name of RETIRED) expect(live).not.toContain(`"${name}"`); + const live = allJobDefinitions().map((job) => job.queue); + for (const name of RETIRED) expect(live).not.toContain(name); }); it("drives the batch through the shared discovery", () => { diff --git a/src/lib/jobs/__tests__/step-consolidation-repair-queue.test.ts b/src/lib/jobs/__tests__/step-consolidation-repair-queue.test.ts index e34c295cd..2f679fb63 100644 --- a/src/lib/jobs/__tests__/step-consolidation-repair-queue.test.ts +++ b/src/lib/jobs/__tests__/step-consolidation-repair-queue.test.ts @@ -1,51 +1,46 @@ /** - * v1.28.37 — step-consolidation repair queue registration guard + - * `extractStatsDay` unit coverage. + * Step-consolidation repair queue registration guard + `extractStatsDay` + * unit coverage. * - * Same source-text-grep approach as the drain + step-consolidation + - * mean-consolidation guards: assert the repair queue is registered in - * `allQueues`, a `createAndWork` handler is wired against it, and the - * boot-discovery enqueue helper fires — without booting pg-boss + Prisma. - * An unregistered queue silently never drains (the dead-queue class). + * Reads the worker registry: the repair queue is declared in the rollup + * group, bound to the repair handler, and offered again by the rollup boot + * discovery. An unregistered queue silently never drains (the dead-queue + * class). */ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - import { describe, expect, it } from "vitest"; -import { extractStatsDay } from "../step-consolidation-repair"; +import { handleStepConsolidationRepair } from "@/lib/jobs/reminder/rollup-handlers"; +import { + discoveryOf, + groupOf, + jobOf, + loadedHandler, +} from "@/lib/jobs/registry/__tests__/registry-helpers"; -const REGISTRAR_PATH = join(__dirname, "..", "reminder", "register-rollup.ts"); -const source = readFileSync(REGISTRAR_PATH, "utf8"); +import { + STEP_CONSOLIDATION_REPAIR_CONCURRENCY, + STEP_CONSOLIDATION_REPAIR_QUEUE, + extractStatsDay, +} from "../step-consolidation-repair"; -describe("reminder-worker — step-consolidation-repair wiring", () => { - it("imports the queue symbols from the repair module", () => { - expect(source).toMatch( - /from\s*["']@\/lib\/jobs\/step-consolidation-repair["']/, - ); - expect(source).toMatch(/\bSTEP_CONSOLIDATION_REPAIR_QUEUE\b/); - expect(source).toMatch(/\brunStepConsolidationRepairForUser\b/); - expect(source).toMatch(/\benqueueBootTimeStepConsolidationRepair\b/); +describe("step-consolidation-repair registration", () => { + it("declares the repair queue in the rollup group", () => { + expect(groupOf(STEP_CONSOLIDATION_REPAIR_QUEUE).name).toBe("rollup"); + const job = jobOf(STEP_CONSOLIDATION_REPAIR_QUEUE); + expect(job.policy?.policy).toBe("exclusive"); + expect(job.work).toEqual({ + localConcurrency: STEP_CONSOLIDATION_REPAIR_CONCURRENCY, + }); }); - it("registers the repair queue in the allQueues loop", () => { - const allQueuesMatch = source.match( - /const allQueues\s*=\s*\[([\s\S]*?)\];/, - ); - expect(allQueuesMatch).not.toBeNull(); - expect(allQueuesMatch![1]).toMatch(/\bSTEP_CONSOLIDATION_REPAIR_QUEUE\b/); - }); - - it("registers a createAndWork handler against the repair queue", () => { - expect(source).toMatch( - /createAndWork[\s\S]{0,200}STEP_CONSOLIDATION_REPAIR_QUEUE[\s\S]{0,400}runStepConsolidationRepairForUser/, + it("binds the repair handler", async () => { + expect(await loadedHandler(STEP_CONSOLIDATION_REPAIR_QUEUE)).toBe( + handleStepConsolidationRepair, ); }); - it("fires the boot-discovery enqueue helper", () => { - expect(source).toMatch( - /await enqueueBootTimeStepConsolidationRepair\([^)]*\)/, - ); + it("runs the boot discovery pass", () => { + expect(discoveryOf("rollup")).toContain("step-consolidation-repair"); }); }); diff --git a/src/lib/jobs/__tests__/stress-strain-retention-queue.test.ts b/src/lib/jobs/__tests__/stress-strain-retention-queue.test.ts index 969d80fba..2595ac000 100644 --- a/src/lib/jobs/__tests__/stress-strain-retention-queue.test.ts +++ b/src/lib/jobs/__tests__/stress-strain-retention-queue.test.ts @@ -1,39 +1,125 @@ /** - * v1.10.0 — computed scores (WX-E). Queue-registration guards for the - * Stress + Strain nightly score jobs and the dense intra-day retention - * drain. + * Computed scores: queue-registration guards for the Stress + Strain nightly + * score jobs and the dense intra-day retention drain. * - * Same source-text-grep approach as the WX-C recovery-score guard: assert - * each queue is imported, registered in `allQueues`, scheduled (the two - * cron-driven score jobs), and wired to a `createAndWork` handler — without - * booting pg-boss + Prisma. An unregistered queue silently never drains - * (the recurring past bug this guards against). + * Reads the worker registry: each queue is declared in its group, scheduled + * (the two cron-driven score jobs), bound to its handler, and offered again by + * boot discovery where it has one — without booting pg-boss + Prisma. An + * unregistered queue silently never drains (the recurring past bug this + * guards against). The retention handler and the nightly drain run against + * mocked passes. */ import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import type { Job } from "pg-boss"; +import { describe, expect, it, vi } from "vitest"; -// v1.18.1 — the queue wiring moved out of the 2143-LOC reminder-worker boot -// file into domain registrars. Stress + Strain live in the status registrar; -// the dense intra-day retention queue (and the nightly fold onto the -// drain-cumulative tick) lives in the rollup registrar. Concatenate both so the -// dead-queue guard follows the wiring into each. -const STATUS_REGISTRAR_PATH = join( - __dirname, - "..", - "reminder", - "register-status.ts", +const { + runDenseIntradayRetention, + runDenseIntradayRetentionForUser, + workerLog, +} = vi.hoisted(() => ({ + runDenseIntradayRetention: vi.fn(async () => ({ + stoppedEarly: false, + totals: { + usersScanned: 0, + daysConsolidated: 0, + perSampleRowsSoftDeleted: 0, + hourlyRowsUpserted: 0, + dailyRowsRetired: 0, + }, + })), + runDenseIntradayRetentionForUser: vi.fn(async () => ({ + daysConsolidated: 1, + perSampleRowsSoftDeleted: 2, + derivedRestingRowsUpserted: 3, + stoppedEarly: false, + })), + workerLog: vi.fn(), +})); +vi.mock( + "@/lib/measurements/dense-intraday-retention", + async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/lib/measurements/dense-intraday-retention") + >()), + runDenseIntradayRetention, + }), ); -const ROLLUP_REGISTRAR_PATH = join( - __dirname, - "..", - "reminder", - "register-rollup.ts", +vi.mock("@/lib/jobs/dense-intraday-retention", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/lib/jobs/dense-intraday-retention") + >()), + runDenseIntradayRetentionForUser, +})); +vi.mock( + "@/lib/measurements/drain-per-sample-cumulative", + async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/lib/measurements/drain-per-sample-cumulative") + >()), + drainPerSampleCumulative: vi.fn(async () => ({ + stoppedEarly: false, + totals: { + usersScanned: 0, + bucketsCollapsed: 0, + perSampleRowsDeleted: 0, + dailyRowsUpserted: 0, + }, + })), + }), ); -const source = - readFileSync(STATUS_REGISTRAR_PATH, "utf8") + - readFileSync(ROLLUP_REGISTRAR_PATH, "utf8"); +vi.mock( + "@/lib/measurements/consolidate-daily-mean", + async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/lib/measurements/consolidate-daily-mean") + >()), + consolidateDailyMean: vi.fn(async () => ({ + stoppedEarly: false, + totals: { + usersScanned: 0, + daysConsolidated: 0, + perSampleRowsSoftDeleted: 0, + dailyRowsUpserted: 0, + daysFailed: 0, + }, + })), + }), +); +vi.mock("@/lib/jobs/reminder/shared", async (importOriginal) => ({ + ...(await importOriginal()), + getWorkerPrisma: () => ({}), + workerLog, +})); + +import { DENSE_INTRADAY_HOURLY_REBUILD_QUEUE } from "@/lib/jobs/dense-intraday-hourly-rebuild-queue"; +import { DENSE_INTRADAY_RETENTION_QUEUE } from "@/lib/jobs/dense-intraday-retention-queue"; +import { DRAIN_CUMULATIVE_QUEUE } from "@/lib/jobs/drain-cumulative-queue"; +import { + handleDenseIntradayHourlyRebuild, + handleDenseIntradayRetention, +} from "@/lib/jobs/reminder/rollup-handlers"; +import { + handleStrainScore, + handleStressScore, +} from "@/lib/jobs/reminder/status-handlers"; +import { + discoveryOf, + groupOf, + jobOf, + loadedHandler, + recordingBoss, +} from "@/lib/jobs/registry/__tests__/registry-helpers"; +import { + STRAIN_SCORE_CRON, + STRAIN_SCORE_QUEUE, +} from "@/lib/jobs/strain-score-queue"; +import { + STRESS_SCORE_CRON, + STRESS_SCORE_QUEUE, +} from "@/lib/jobs/stress-score-queue"; const DENSE_RETENTION_PATH = join( __dirname, @@ -42,91 +128,70 @@ const DENSE_RETENTION_PATH = join( ); const denseRetentionSource = readFileSync(DENSE_RETENTION_PATH, "utf8"); -function allQueuesBlock(): string { - // Each registrar owns its own `const allQueues = [...]`; union every block so - // a queue registered in either the status or rollup registrar satisfies the - // membership assertion. - const blocks = [...source.matchAll(/const allQueues\s*=\s*\[([\s\S]*?)\];/g)]; - expect(blocks.length).toBeGreaterThan(0); - return blocks.map((m) => m[1]).join("\n"); +function job(name: string, data: T): Job { + return { + id: "j1", + name, + data, + expireInSeconds: 900, + heartbeatSeconds: null, + retryCount: 0, + signal: new AbortController().signal, + }; } -describe("reminder-worker — stress-score wiring", () => { - it("imports the queue symbols from the stress-score module", () => { - expect(source).toMatch(/from\s*["']@\/lib\/jobs\/stress-score["']/); - expect(source).toMatch(/\bSTRESS_SCORE_QUEUE\b/); - expect(source).toMatch(/\bSTRESS_SCORE_CRON\b/); - expect(source).toMatch(/\brunStressScore\b/); - }); - - it("registers the stress-score queue in allQueues", () => { - expect(allQueuesBlock()).toMatch(/\bSTRESS_SCORE_QUEUE\b/); +describe("stress-score registration", () => { + it("declares the stress-score queue in the status group", () => { + expect(groupOf(STRESS_SCORE_QUEUE).name).toBe("status"); }); it("schedules the stress-score cron", () => { - expect(source).toMatch(/\[STRESS_SCORE_QUEUE,\s*STRESS_SCORE_CRON\]/); + expect(jobOf(STRESS_SCORE_QUEUE).cron).toBe(STRESS_SCORE_CRON); }); - it("registers a createAndWork handler that runs the stress-score pass", () => { - expect(source).toMatch( - /createAndWork[\s\S]{0,200}STRESS_SCORE_QUEUE[\s\S]{0,400}runStressScore/, - ); + it("binds the handler that runs the stress-score pass", async () => { + expect(await loadedHandler(STRESS_SCORE_QUEUE)).toBe(handleStressScore); }); }); -describe("reminder-worker — strain-score wiring", () => { - it("imports the queue symbols from the strain-score module", () => { - expect(source).toMatch(/from\s*["']@\/lib\/jobs\/strain-score["']/); - expect(source).toMatch(/\bSTRAIN_SCORE_QUEUE\b/); - expect(source).toMatch(/\bSTRAIN_SCORE_CRON\b/); - expect(source).toMatch(/\brunStrainScore\b/); - }); - - it("registers the strain-score queue in allQueues", () => { - expect(allQueuesBlock()).toMatch(/\bSTRAIN_SCORE_QUEUE\b/); +describe("strain-score registration", () => { + it("declares the strain-score queue in the status group", () => { + expect(groupOf(STRAIN_SCORE_QUEUE).name).toBe("status"); }); it("schedules the strain-score cron", () => { - expect(source).toMatch(/\[STRAIN_SCORE_QUEUE,\s*STRAIN_SCORE_CRON\]/); + expect(jobOf(STRAIN_SCORE_QUEUE).cron).toBe(STRAIN_SCORE_CRON); }); - it("registers a createAndWork handler that runs the strain-score pass", () => { - expect(source).toMatch( - /createAndWork[\s\S]{0,200}STRAIN_SCORE_QUEUE[\s\S]{0,400}runStrainScore/, - ); + it("binds the handler that runs the strain-score pass", async () => { + expect(await loadedHandler(STRAIN_SCORE_QUEUE)).toBe(handleStrainScore); }); }); -describe("reminder-worker — dense intra-day retention wiring", () => { - it("imports the queue symbols from the dense-intraday-retention module", () => { - expect(source).toMatch( - /from\s*["']@\/lib\/jobs\/dense-intraday-retention["']/, - ); - expect(source).toMatch(/\bDENSE_INTRADAY_RETENTION_QUEUE\b/); - expect(source).toMatch(/\brunDenseIntradayRetentionForUser\b/); - expect(source).toMatch(/\benqueueBootTimeDenseIntradayRetention\b/); - }); - - it("registers the dense-intraday-retention queue in allQueues", () => { - expect(allQueuesBlock()).toMatch(/\bDENSE_INTRADAY_RETENTION_QUEUE\b/); - }); - - it("registers a createAndWork handler against the dense-intraday-retention queue", () => { - expect(source).toMatch( - /createAndWork[\s\S]{0,250}DENSE_INTRADAY_RETENTION_QUEUE[\s\S]{0,500}runDenseIntradayRetentionForUser/, +describe("dense intra-day retention registration", () => { + it("declares the dense-intraday-retention queue in the rollup group", () => { + expect(groupOf(DENSE_INTRADAY_RETENTION_QUEUE).name).toBe("rollup"); + expect(jobOf(DENSE_INTRADAY_RETENTION_QUEUE).policy?.policy).toBe( + "exclusive", ); }); - it("fires the boot-discovery enqueue helper", () => { - expect(source).toMatch(/await enqueueBootTimeDenseIntradayRetention\(\)/); + it("binds the per-user retention handler", async () => { + expect(await loadedHandler(DENSE_INTRADAY_RETENTION_QUEUE)).toBe( + handleDenseIntradayRetention, + ); + }); + + it("fires the boot-discovery pass", () => { + expect(discoveryOf("rollup")).toContain("dense-intraday-retention"); }); it("defers the boot-discovery drain past the startup storm (P2028 guard)", () => { // The boot-time per-user retention jobs MUST carry a startAfter delay so // the transaction-per-day drain never contends with the deploy storm + // migration + other boot backfills + foreground health-checks for the - // shared connection pool. Dropping this re-opens the v1.10.0 pool- - // exhaustion (P2028) restart loop on a data-heavy tenant. + // shared connection pool. Dropping this re-opens the pool-exhaustion + // (P2028) restart loop on a data-heavy tenant. expect(denseRetentionSource).toMatch( /DENSE_INTRADAY_RETENTION_BOOT_DELAY_SECONDS\s*=\s*\d+/, ); @@ -135,44 +200,52 @@ describe("reminder-worker — dense intra-day retention wiring", () => { ); }); - it("folds the nightly dense-retention walk onto the drain-cumulative tick", () => { + it("folds the nightly dense-retention walk onto the drain-cumulative tick", async () => { // The steady-state nightly walk runs the global (no-userId) pass on the // same tick as the cumulative + mean drains. - expect(source).toMatch(/runDenseIntradayRetention\(\s*getWorkerPrisma\(\)/); + const { boss } = recordingBoss(); + const drain = await jobOf(DRAIN_CUMULATIVE_QUEUE).load({ boss }); + await drain([job(DRAIN_CUMULATIVE_QUEUE, {})]); + expect(runDenseIntradayRetention).toHaveBeenCalledTimes(1); + expect(runDenseIntradayRetention).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ dryRun: false }), + ); }); - it("surfaces the iOS#34 derived-resting count in the per-user worker log", () => { + it("surfaces the iOS#34 derived-resting count in the per-user worker log", async () => { // The PULSE facet mints derived RESTING_HEART_RATE rows for proxy users; // the per-user handler returns the count and the worker logs it so the // signal-preservation is observable in production. - expect(source).toMatch(/derivedRestingRowsUpserted/); + const outcome = await handleDenseIntradayRetention([ + job(DENSE_INTRADAY_RETENTION_QUEUE, { + userId: "u1", + enqueuedAt: "2026-01-01T00:00:00.000Z", + }), + ]); + expect(outcome).toMatchObject({ + did: { derived_resting_rows_upserted: 3 }, + }); + expect(workerLog).toHaveBeenCalledWith( + "info", + expect.stringContaining("derivedRestingRowsUpserted=3"), + ); }); }); -describe("reminder-worker — dense hourly-rebuild wiring", () => { - it("imports the queue symbols from the dense-intraday-hourly-rebuild module", () => { - expect(source).toMatch( - /from\s*["']@\/lib\/jobs\/dense-intraday-hourly-rebuild["']/, - ); - expect(source).toMatch(/\bDENSE_INTRADAY_HOURLY_REBUILD_QUEUE\b/); - expect(source).toMatch(/\brunDenseIntradayHourlyRebuildForUser\b/); - expect(source).toMatch(/\benqueueBootTimeDenseIntradayHourlyRebuild\b/); +describe("dense hourly-rebuild registration", () => { + it("declares the hourly-rebuild queue in the rollup group", () => { + expect(groupOf(DENSE_INTRADAY_HOURLY_REBUILD_QUEUE).name).toBe("rollup"); }); - it("registers the hourly-rebuild queue in allQueues", () => { - expect(allQueuesBlock()).toMatch(/\bDENSE_INTRADAY_HOURLY_REBUILD_QUEUE\b/); - }); - - it("registers a createAndWork handler against the hourly-rebuild queue", () => { - expect(source).toMatch( - /createAndWork[\s\S]{0,250}DENSE_INTRADAY_HOURLY_REBUILD_QUEUE[\s\S]{0,500}runDenseIntradayHourlyRebuildForUser/, + it("binds the hourly-rebuild handler", async () => { + expect(await loadedHandler(DENSE_INTRADAY_HOURLY_REBUILD_QUEUE)).toBe( + handleDenseIntradayHourlyRebuild, ); }); - it("fires the boot-discovery enqueue helper", () => { - expect(source).toMatch( - /await enqueueBootTimeDenseIntradayHourlyRebuild\(\)/, - ); + it("fires the boot-discovery pass", () => { + expect(discoveryOf("rollup")).toContain("dense-intraday-hourly-rebuild"); }); it("defers the boot-discovery rebuild past the startup storm (P2028 guard)", () => { diff --git a/src/lib/jobs/__tests__/subsystem-surface.test.ts b/src/lib/jobs/__tests__/subsystem-surface.test.ts index bb32673db..d416cb006 100644 --- a/src/lib/jobs/__tests__/subsystem-surface.test.ts +++ b/src/lib/jobs/__tests__/subsystem-surface.test.ts @@ -1,92 +1,17 @@ -import { existsSync, readFileSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; +/** + * Every queue the worker registry declares has a failure audience in + * `SUBSYSTEM_SURFACE`, and the table names no queue the registry has dropped. + */ import { describe, expect, it } from "vitest"; +import { allJobDefinitions } from "@/lib/jobs/registry"; import { SUBSYSTEM_SURFACE, type SubsystemSurface, } from "@/lib/jobs/subsystem-surface"; -const REGISTRARS = [ - "register-integration-sync.ts", - "register-status.ts", - "register-rollup.ts", - "register-reminders.ts", - "register-maintenance.ts", -].map((file) => join(process.cwd(), "src/lib/jobs/reminder", file)); - -function withoutComments(source: string): string { - return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, ""); -} - -export function extractAllQueueBindings(source: string): string[] { - const match = /const\s+allQueues\s*=\s*\[([\s\S]*?)\n\s*\];/.exec(source); - if (!match) throw new Error("Registrar has no allQueues array"); - - return withoutComments(match[1]) - .split(",") - .map((entry) => entry.trim()) - .filter(Boolean) - .map((entry) => { - if (!/^[A-Z][A-Z0-9_]*$/.test(entry)) { - throw new Error(`Unsupported allQueues entry: ${entry}`); - } - return entry; - }); -} - -function literalPattern(binding: string): RegExp { - return new RegExp( - `(?:export\\s+)?const\\s+${binding}(?:\\s*:[^=;]+)?\\s*=\\s*["']([^"']+)["']`, - ); -} - -function modulePath(fromFile: string, specifier: string): string { - const base = specifier.startsWith("@/") - ? resolve(process.cwd(), "src", specifier.slice(2)) - : resolve(dirname(fromFile), specifier); - const candidates = [`${base}.ts`, `${base}.tsx`, join(base, "index.ts")]; - const found = candidates.find((candidate) => existsSync(candidate)); - if (!found) throw new Error(`Cannot resolve ${specifier} from ${fromFile}`); - return found; -} - -function resolveQueueBinding( - binding: string, - file: string, - seen: Set = new Set(), -): string { - const visit = `${file}:${binding}`; - if (seen.has(visit)) throw new Error(`Cyclic queue binding: ${visit}`); - seen.add(visit); - - const source = withoutComments(readFileSync(file, "utf8")); - const local = literalPattern(binding).exec(source); - if (local) return local[1]; - - const imports = /import\s*{([\s\S]*?)}\s*from\s*["']([^"']+)["'];?/g; - for (const match of source.matchAll(imports)) { - for (const rawToken of match[1].split(",")) { - const token = rawToken.trim().replace(/^type\s+/, ""); - if (!token) continue; - const [imported, alias] = token - .split(/\s+as\s+/) - .map((part) => part.trim()); - if ((alias ?? imported) !== binding) continue; - return resolveQueueBinding(imported, modulePath(file, match[2]), seen); - } - } - - throw new Error(`Cannot resolve queue binding ${binding} from ${file}`); -} - function registeredQueueNames(): string[] { - return REGISTRARS.flatMap((file) => { - const source = readFileSync(file, "utf8"); - return extractAllQueueBindings(source).map((binding) => - resolveQueueBinding(binding, file), - ); - }); + return allJobDefinitions().map((job) => job.queue); } export function findSurfaceDrift( @@ -113,8 +38,9 @@ function assertSurfaceComplete( } describe("SUBSYSTEM_SURFACE", () => { - it("classifies every current registrar queue with no stale entries", () => { + it("classifies every current registry queue with no stale entries", () => { const names = registeredQueueNames(); + expect(names.length).toBeGreaterThan(0); expect(new Set(names).size).toBe(names.length); expect(() => assertSurfaceComplete(names, SUBSYSTEM_SURFACE)).not.toThrow(); }); @@ -126,24 +52,9 @@ describe("SUBSYSTEM_SURFACE", () => { } }); - it("catches a new allQueues binding without a surface entry", () => { - const fixture = ` - const EXISTING_QUEUE = "existing-queue"; - const NEW_QUEUE = "new-queue"; - const allQueues = [ - EXISTING_QUEUE, - NEW_QUEUE, - ]; - `; - const names = extractAllQueueBindings(fixture).map((binding) => { - const match = literalPattern(binding).exec(withoutComments(fixture)); - if (!match) - throw new Error(`Fixture binding did not resolve: ${binding}`); - return match[1]; - }); - + it("catches a new registry queue without a surface entry", () => { expect(() => - assertSurfaceComplete(names, { + assertSurfaceComplete(["existing-queue", "new-queue"], { "existing-queue": { audience: "system" }, }), ).toThrow(/missing=\[new-queue\]/); diff --git a/src/lib/jobs/__tests__/tls-pin-monitor-queue.test.ts b/src/lib/jobs/__tests__/tls-pin-monitor-queue.test.ts index 9fe67d0b0..544707125 100644 --- a/src/lib/jobs/__tests__/tls-pin-monitor-queue.test.ts +++ b/src/lib/jobs/__tests__/tls-pin-monitor-queue.test.ts @@ -1,63 +1,56 @@ /** - * v1.12.2 ios-coord — TLS-pin-monitor queue registration guard. + * TLS-pin-monitor queue registration guard. * - * Same source-text-grep approach as the other queue-wiring guards: assert - * the queue is imported, registered in `allQueues`, scheduled, and wired to - * a `createAndWork` handler — without booting pg-boss + Prisma. An unregistered - * queue silently never drains (the recurring v1.4.37 dead-queue bug), which - * would leave the pinned-leaf-rotation alarm dark. + * Reads the worker registry: the queue is declared in the maintenance group, + * scheduled on its cron with the default retries, and bound to the handler + * that runs the monitor pass. An unregistered queue silently never drains + * (the dead-queue class), which would leave the pinned-leaf-rotation alarm + * dark. */ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -// v1.18.1 — the TLS-pin-monitor wiring moved out of the 2143-LOC -// reminder-worker boot file into the maintenance registrar. The dead-queue -// guard follows the wiring there (the ops-handlers module is still concatenated -// for the handler-body `runTlsPinMonitor` assertion). -const REGISTRAR_PATH = join( - __dirname, - "..", - "reminder", - "register-maintenance.ts", +import { handleTlsPinMonitor } from "@/lib/jobs/reminder/ops-handlers"; +import { + groupOf, + jobOf, + loadedHandler, +} from "@/lib/jobs/registry/__tests__/registry-helpers"; +import { + TLS_PIN_MONITOR_CRON, + TLS_PIN_MONITOR_QUEUE, +} from "@/lib/jobs/tls-pin-monitor-queue"; + +const handlerSource = readFileSync( + join(__dirname, "..", "reminder", "ops-handlers.ts"), + "utf8", ); -const workerSource = - readFileSync(REGISTRAR_PATH, "utf8") + - readFileSync(join(__dirname, "..", "reminder", "ops-handlers.ts"), "utf8"); -describe("reminder-worker — tls-pin-monitor wiring", () => { - it("imports the queue symbols from the tls-pin-monitor module", () => { - expect(workerSource).toMatch( - /from\s*["']@\/lib\/jobs\/tls-pin-monitor["']/, - ); - expect(workerSource).toMatch(/\bTLS_PIN_MONITOR_QUEUE\b/); - expect(workerSource).toMatch(/\bTLS_PIN_MONITOR_CRON\b/); - expect(workerSource).toMatch(/\brunTlsPinMonitor\b/); +describe("tls-pin-monitor registration", () => { + it("imports the monitor pass into the ops handlers", () => { + expect(handlerSource).toMatch(/\brunTlsPinMonitor\b/); }); - it("registers the tls-pin-monitor queue in the allQueues loop", () => { - const allQueuesMatch = workerSource.match( - /const allQueues\s*=\s*\[([\s\S]*?)\];/, - ); - expect(allQueuesMatch).not.toBeNull(); - expect(allQueuesMatch![1]).toMatch(/\bTLS_PIN_MONITOR_QUEUE\b/); + it("declares the tls-pin-monitor queue in the maintenance group", () => { + expect(groupOf(TLS_PIN_MONITOR_QUEUE).name).toBe("maintenance"); }); - it("schedules the tls-pin-monitor cron", () => { - expect(workerSource).toMatch( - /\[TLS_PIN_MONITOR_QUEUE,\s*TLS_PIN_MONITOR_CRON\]/, - ); + it("schedules the tls-pin-monitor cron with the default retries", () => { + const job = jobOf(TLS_PIN_MONITOR_QUEUE); + expect(job.cron).toBe(TLS_PIN_MONITOR_CRON); + expect(job.scheduleOptions).toBeUndefined(); }); - it("registers a createAndWork handler for the queue", () => { - expect(workerSource).toMatch( - /createAndWork[\s\S]{0,200}TLS_PIN_MONITOR_QUEUE[\s\S]{0,200}handleTlsPinMonitor/, + it("binds the monitor handler", async () => { + expect(await loadedHandler(TLS_PIN_MONITOR_QUEUE)).toBe( + handleTlsPinMonitor, ); }); it("runs the monitor pass inside the handler", () => { - expect(workerSource).toMatch( + expect(handlerSource).toMatch( /handleTlsPinMonitor[\s\S]{0,400}runTlsPinMonitor/, ); }); diff --git a/src/lib/jobs/__tests__/whoop-queues.test.ts b/src/lib/jobs/__tests__/whoop-queues.test.ts index a1ad468b4..cdea7c9f2 100644 --- a/src/lib/jobs/__tests__/whoop-queues.test.ts +++ b/src/lib/jobs/__tests__/whoop-queues.test.ts @@ -1,113 +1,126 @@ /** - * v1.11.0 — pg-boss queue registration for the WHOOP sync layer. + * pg-boss queue registration for the WHOOP sync layer. * - * The reminder-worker imports heavy infrastructure (pg-boss, Prisma adapter, - * notification dispatchers, …) so we don't boot the whole worker here. Instead - * we read the source as text and assert the queue constants + cron schedules + - * handler wiring + allQueues + boot-enqueue are present — the fast guard that - * catches the v1.4.37 dead-queue class (a queue declared but never registered - * in `allQueues`, which silently never drains). + * Reads the worker registry instead of booting the worker: every WHOOP queue + * is declared in the integration-sync group with its cron and handler, the + * backfill forwards to the shared admission lane, and the boot discovery + * offers it again. This is the fast guard for the dead-queue class (a queue + * declared but never registered, which silently never drains). */ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -// v1.18.1 — the queue wiring + boot discovery moved out of the 2143-LOC -// reminder-worker boot file into domain registrars; the WHOOP queues live in -// the integration-sync registrar. The dead-queue guard follows the wiring there -// (the handler module is still concatenated for the import-routine assertions). -const REGISTRAR_PATH = join( - __dirname, - "..", - "reminder", - "register-integration-sync.ts", -); -const source = - readFileSync(REGISTRAR_PATH, "utf8") + - readFileSync(join(__dirname, "..", "reminder", "whoop-sync.ts"), "utf8"); +import { INTEGRATION_BACKFILL_ADMISSION_QUEUE } from "@/lib/jobs/integration-backfill-admission"; +import { + WHOOP_CYCLE_SYNC_CRON, + WHOOP_CYCLE_SYNC_QUEUE, + WHOOP_OAUTH_STATE_CLEANUP_CRON, + WHOOP_OAUTH_STATE_CLEANUP_QUEUE, + WHOOP_RECOVERY_SYNC_CRON, + WHOOP_RECOVERY_SYNC_QUEUE, + WHOOP_SLEEP_SYNC_CRON, + WHOOP_SLEEP_SYNC_QUEUE, + WHOOP_WORKOUT_SYNC_CRON, + WHOOP_WORKOUT_SYNC_QUEUE, +} from "@/lib/jobs/integration-poll-queues"; +import { cronIsTheRetry } from "@/lib/jobs/reminder/registrar-shared"; +import { + handleWhoopCycleSync, + handleWhoopRecoverySync, + handleWhoopSleepSync, + handleWhoopWorkoutSync, +} from "@/lib/jobs/reminder/whoop-sync"; +import { + discoveryOf, + groupOf, + jobOf, + loadedHandler, + recordingBoss, +} from "@/lib/jobs/registry/__tests__/registry-helpers"; +import { WHOOP_BACKFILL_QUEUE } from "@/lib/jobs/whoop-backfill-queue"; -const WHOOP_QUEUE_CONSTS = [ - "WHOOP_RECOVERY_SYNC_QUEUE", - "WHOOP_SLEEP_SYNC_QUEUE", - "WHOOP_WORKOUT_SYNC_QUEUE", - "WHOOP_CYCLE_SYNC_QUEUE", - "WHOOP_BACKFILL_QUEUE", - "WHOOP_OAUTH_STATE_CLEANUP_QUEUE", +const handlerSource = readFileSync( + join(__dirname, "..", "reminder", "whoop-sync.ts"), + "utf8", +); + +const WHOOP_QUEUES = [ + WHOOP_RECOVERY_SYNC_QUEUE, + WHOOP_SLEEP_SYNC_QUEUE, + WHOOP_WORKOUT_SYNC_QUEUE, + WHOOP_CYCLE_SYNC_QUEUE, + WHOOP_BACKFILL_QUEUE, + WHOOP_OAUTH_STATE_CLEANUP_QUEUE, ] as const; -describe("reminder-worker — WHOOP sync queues", () => { +describe("WHOOP sync queues", () => { it("declares every WHOOP sync queue constant", () => { - expect(source).toMatch( - /WHOOP_RECOVERY_SYNC_QUEUE\s*=\s*["']whoop-recovery-sync["']/, - ); - expect(source).toMatch( - /WHOOP_SLEEP_SYNC_QUEUE\s*=\s*["']whoop-sleep-sync["']/, - ); - expect(source).toMatch( - /WHOOP_WORKOUT_SYNC_QUEUE\s*=\s*["']whoop-workout-sync["']/, - ); - expect(source).toMatch( - /WHOOP_CYCLE_SYNC_QUEUE\s*=\s*["']whoop-cycle-sync["']/, - ); + expect(WHOOP_RECOVERY_SYNC_QUEUE).toBe("whoop-recovery-sync"); + expect(WHOOP_SLEEP_SYNC_QUEUE).toBe("whoop-sleep-sync"); + expect(WHOOP_WORKOUT_SYNC_QUEUE).toBe("whoop-workout-sync"); + expect(WHOOP_CYCLE_SYNC_QUEUE).toBe("whoop-cycle-sync"); }); - it("registers EVERY WHOOP queue in allQueues (v1.4.37 dead-queue guard)", () => { - // Isolate the allQueues array body so the assertion can't be satisfied by - // an incidental mention elsewhere in the file. - const block = /const allQueues = \[([\s\S]*?)\];/.exec(source); - expect(block).not.toBeNull(); - const body = block![1]!; - for (const q of WHOOP_QUEUE_CONSTS) { - expect(body).toContain(q); + it("declares EVERY WHOOP queue in the integration-sync group", () => { + for (const q of WHOOP_QUEUES) { + expect(groupOf(q).name).toBe("integration_sync"); } }); it("schedules the four poll crons + the daily oauth-state sweep", () => { - expect(source).toMatch( - /\[WHOOP_RECOVERY_SYNC_QUEUE,\s*WHOOP_RECOVERY_SYNC_CRON\]/, - ); - expect(source).toMatch( - /\[WHOOP_SLEEP_SYNC_QUEUE,\s*WHOOP_SLEEP_SYNC_CRON\]/, - ); - expect(source).toMatch( - /\[WHOOP_WORKOUT_SYNC_QUEUE,\s*WHOOP_WORKOUT_SYNC_CRON\]/, - ); - expect(source).toMatch( - /\[WHOOP_CYCLE_SYNC_QUEUE,\s*WHOOP_CYCLE_SYNC_CRON\]/, - ); - expect(source).toMatch( - /\[\s*WHOOP_OAUTH_STATE_CLEANUP_QUEUE,\s*WHOOP_OAUTH_STATE_CLEANUP_CRON,\s*cronIsTheRetry,?\s*\]/, + expect(jobOf(WHOOP_RECOVERY_SYNC_QUEUE).cron).toBe( + WHOOP_RECOVERY_SYNC_CRON, ); + expect(jobOf(WHOOP_SLEEP_SYNC_QUEUE).cron).toBe(WHOOP_SLEEP_SYNC_CRON); + expect(jobOf(WHOOP_WORKOUT_SYNC_QUEUE).cron).toBe(WHOOP_WORKOUT_SYNC_CRON); + expect(jobOf(WHOOP_CYCLE_SYNC_QUEUE).cron).toBe(WHOOP_CYCLE_SYNC_CRON); + const sweep = jobOf(WHOOP_OAUTH_STATE_CLEANUP_QUEUE); + expect(sweep.cron).toBe(WHOOP_OAUTH_STATE_CLEANUP_CRON); + expect(sweep.scheduleOptions).toBe(cronIsTheRetry); }); - it("registers a createAndWork handler for every WHOOP sync queue", () => { - expect(source).toMatch( - /createAndWork[\s\S]{0,160}WHOOP_RECOVERY_SYNC_QUEUE[\s\S]{0,160}handleWhoopRecoverySync/, + it("binds a handler for every WHOOP sync queue", async () => { + expect(await loadedHandler(WHOOP_RECOVERY_SYNC_QUEUE)).toBe( + handleWhoopRecoverySync, ); - expect(source).toMatch( - /createAndWork[\s\S]{0,160}WHOOP_SLEEP_SYNC_QUEUE[\s\S]{0,160}handleWhoopSleepSync/, + expect(await loadedHandler(WHOOP_SLEEP_SYNC_QUEUE)).toBe( + handleWhoopSleepSync, ); - expect(source).toMatch( - /createAndWork[\s\S]{0,160}WHOOP_WORKOUT_SYNC_QUEUE[\s\S]{0,160}handleWhoopWorkoutSync/, + expect(await loadedHandler(WHOOP_WORKOUT_SYNC_QUEUE)).toBe( + handleWhoopWorkoutSync, ); - expect(source).toMatch( - /createAndWork[\s\S]{0,160}WHOOP_CYCLE_SYNC_QUEUE[\s\S]{0,160}handleWhoopCycleSync/, - ); - expect(source).toMatch( - /createAndWork[\s\S]{0,200}WHOOP_BACKFILL_QUEUE[\s\S]{0,260}enqueueIntegrationBackfillAdmission/, + expect(await loadedHandler(WHOOP_CYCLE_SYNC_QUEUE)).toBe( + handleWhoopCycleSync, ); + const { boss, sends } = recordingBoss(); + const backfill = await jobOf(WHOOP_BACKFILL_QUEUE).load({ boss }); + await backfill([ + { + id: "j1", + name: WHOOP_BACKFILL_QUEUE, + data: { userId: "u1" }, + expireInSeconds: 60, + heartbeatSeconds: null, + retryCount: 0, + signal: new AbortController().signal, + }, + ]); + expect(sends).toHaveLength(1); + expect(sends[0]!.queue).toBe(INTEGRATION_BACKFILL_ADMISSION_QUEUE); + expect(sends[0]!.data).toMatchObject({ kind: "whoop-backfill" }); }); it("wires the self-converging WHOOP backfill boot discovery", () => { - expect(source).toMatch( - /enqueueBootTimeWhoopBackfill\(\s*bootStaggerSecondsFor\("whoop-backfill"\)/, - ); + expect(discoveryOf("integration_sync")).toContain("whoop-backfill"); }); it("imports the WHOOP per-resource sync routines", () => { - expect(source).toMatch(/from\s*["']@\/lib\/whoop\/sync-recovery["']/); - expect(source).toMatch(/from\s*["']@\/lib\/whoop\/sync-cycle["']/); - expect(source).toMatch(/from\s*["']@\/lib\/whoop\/sync-workout["']/); + expect(handlerSource).toMatch( + /from\s*["']@\/lib\/whoop\/sync-recovery["']/, + ); + expect(handlerSource).toMatch(/from\s*["']@\/lib\/whoop\/sync-cycle["']/); + expect(handlerSource).toMatch(/from\s*["']@\/lib\/whoop\/sync-workout["']/); }); }); diff --git a/src/lib/jobs/__tests__/withings-ecg-worker.test.ts b/src/lib/jobs/__tests__/withings-ecg-worker.test.ts index 457a24121..73a2358bd 100644 --- a/src/lib/jobs/__tests__/withings-ecg-worker.test.ts +++ b/src/lib/jobs/__tests__/withings-ecg-worker.test.ts @@ -1,7 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Job } from "pg-boss"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; const { syncUserEcg } = vi.hoisted(() => ({ syncUserEcg: vi.fn< @@ -28,7 +26,8 @@ vi.mock("@/lib/withings/sync-activity", () => ({ vi.mock("@/lib/withings/sync-sleep", () => ({ syncUserSleep: vi.fn(async () => 0), })); -vi.mock("@/lib/jobs/reminder-satisfy", () => ({ +vi.mock("@/lib/jobs/reminder-satisfy", async (importOriginal) => ({ + ...(await importOriginal()), enqueueReminderSatisfy: vi.fn(async () => undefined), })); vi.mock("@/lib/jobs/worker-status", () => ({ @@ -72,6 +71,13 @@ import { handleWithingsEcgSync, type WithingsEcgSyncPayload, } from "../reminder/withings-sync"; +import { WITHINGS_ECG_SYNC_CRON } from "../integration-poll-queues"; +import { + groupOf, + jobOf, + loadedHandler, +} from "../registry/__tests__/registry-helpers"; +import { WITHINGS_ECG_SYNC_QUEUE } from "../withings-ecg-queue"; function job(data: WithingsEcgSyncPayload): Job { return { data } as Job; @@ -82,10 +88,6 @@ beforeEach(() => { ecgFindMany.mockResolvedValue([]); isReauthRequired.mockResolvedValue(false); }); -const registrarSource = readFileSync( - join(process.cwd(), "src/lib/jobs/reminder/register-integration-sync.ts"), - "utf8", -); describe("handleWithingsEcgSync", () => { it("a webhook job (userId + window) does a targeted date-windowed sync", async () => { @@ -176,17 +178,13 @@ describe("handleWithingsEcgSync", () => { }); describe("withings ECG queue registration", () => { - it("deduplicates only queued replays and admits a rescue after work starts", () => { - const allQueues = registrarSource.match( - /const allQueues\s*=\s*\[([\s\S]*?)\];/, - ); - expect(allQueues).not.toBeNull(); - expect(allQueues![1]).toMatch(/\bWITHINGS_ECG_SYNC_QUEUE\b/); - expect(registrarSource).toMatch( - /\[WITHINGS_ECG_SYNC_QUEUE\]:\s*\{[\s\S]*?policy:\s*"short"/, - ); - expect(registrarSource).toMatch( - /createAndWork\(\s*boss,\s*WITHINGS_ECG_SYNC_QUEUE[\s\S]{0,120}handleWithingsEcgSync/, + it("deduplicates only queued replays and admits a rescue after work starts", async () => { + expect(groupOf(WITHINGS_ECG_SYNC_QUEUE).name).toBe("integration_sync"); + const definition = jobOf(WITHINGS_ECG_SYNC_QUEUE); + expect(definition.policy?.policy).toBe("short"); + expect(definition.cron).toBe(WITHINGS_ECG_SYNC_CRON); + expect(await loadedHandler(WITHINGS_ECG_SYNC_QUEUE)).toBe( + handleWithingsEcgSync, ); }); }); diff --git a/src/lib/jobs/__tests__/withings-queues.test.ts b/src/lib/jobs/__tests__/withings-queues.test.ts index 8a5bd52ec..0cd5a286b 100644 --- a/src/lib/jobs/__tests__/withings-queues.test.ts +++ b/src/lib/jobs/__tests__/withings-queues.test.ts @@ -1,90 +1,83 @@ /** - * v1.4.25 — pg-boss queue registration for Withings activity + - * sleep v2 syncs. + * pg-boss queue registration for Withings activity + sleep v2 syncs. * - * The reminder-worker imports heavy infrastructure (pg-boss, Prisma - * adapter, notification dispatchers, …) so we don't boot the whole - * worker in this test. Instead we read the source file as text and - * assert the queue constants + cron schedules + handler wiring are - * present — a fast regression guard that catches accidental deletion - * of the W17b/c plumbing without spinning up Postgres. + * Reads the worker registry instead of booting the worker (pg-boss, Prisma + * adapter, notification dispatchers, …): the queues are declared with their + * crons and bound to their handlers, a fast regression guard against + * accidental deletion of the plumbing without spinning up Postgres. The + * import-routine assertions read the handler module. */ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -// v1.18.1 — the queue wiring moved out of the 2143-LOC reminder-worker boot -// file into domain registrars; the Withings queues live in the integration-sync -// registrar. The dead-queue guard follows the wiring there (the handler module -// is still concatenated for the import-routine assertions below). -const REGISTRAR_PATH = join( - __dirname, - "..", - "reminder", - "register-integration-sync.ts", +import { + WITHINGS_ACTIVITY_CRON, + WITHINGS_ACTIVITY_QUEUE, + WITHINGS_ECG_SYNC_CRON, + WITHINGS_SLEEP_CRON, + WITHINGS_SLEEP_QUEUE, +} from "@/lib/jobs/integration-poll-queues"; +import { + handleWithingsActivitySync, + handleWithingsSleepSync, +} from "@/lib/jobs/reminder/withings-sync"; +import { + groupOf, + jobOf, + loadedHandler, +} from "@/lib/jobs/registry/__tests__/registry-helpers"; +import { WITHINGS_ECG_SYNC_QUEUE } from "@/lib/jobs/withings-ecg-queue"; + +const handlerSource = readFileSync( + join(__dirname, "..", "reminder", "withings-sync.ts"), + "utf8", ); -const source = - readFileSync(REGISTRAR_PATH, "utf8") + - readFileSync(join(__dirname, "..", "reminder", "withings-sync.ts"), "utf8"); - -describe("reminder-worker — Withings activity + sleep v2 queues", () => { +describe("Withings activity + sleep v2 queues", () => { it("declares the activity-sync queue at the documented cadence", () => { - expect(source).toMatch( - /WITHINGS_ACTIVITY_QUEUE\s*=\s*["']withings-activity-sync["']/, - ); - expect(source).toMatch( - /WITHINGS_ACTIVITY_CRON\s*=\s*["']0 \* \* \* \*["']/, - ); + expect(WITHINGS_ACTIVITY_QUEUE).toBe("withings-activity-sync"); + expect(WITHINGS_ACTIVITY_CRON).toBe("0 * * * *"); }); it("declares the sleep-sync queue at the offset cadence", () => { - expect(source).toMatch( - /WITHINGS_SLEEP_QUEUE\s*=\s*["']withings-sleep-sync["']/, - ); + expect(WITHINGS_SLEEP_QUEUE).toBe("withings-sleep-sync"); // :15 offset keeps it out of lockstep with the measure cron at :00. - expect(source).toMatch(/WITHINGS_SLEEP_CRON\s*=\s*["']15 \* \* \* \*["']/); + expect(WITHINGS_SLEEP_CRON).toBe("15 * * * *"); }); - it("registers the activity-sync handler against pg-boss", () => { - expect(source).toMatch(/handleWithingsActivitySync\b/); - expect(source).toMatch( - /createAndWork[\s\S]{0,200}WITHINGS_ACTIVITY_QUEUE[\s\S]{0,200}handleWithingsActivitySync/, + it("binds the activity-sync handler", async () => { + expect(groupOf(WITHINGS_ACTIVITY_QUEUE).name).toBe("integration_sync"); + expect(await loadedHandler(WITHINGS_ACTIVITY_QUEUE)).toBe( + handleWithingsActivitySync, ); }); - it("registers the sleep-sync handler against pg-boss", () => { - expect(source).toMatch(/handleWithingsSleepSync\b/); - expect(source).toMatch( - /createAndWork[\s\S]{0,200}WITHINGS_SLEEP_QUEUE[\s\S]{0,200}handleWithingsSleepSync/, + it("binds the sleep-sync handler", async () => { + expect(groupOf(WITHINGS_SLEEP_QUEUE).name).toBe("integration_sync"); + expect(await loadedHandler(WITHINGS_SLEEP_QUEUE)).toBe( + handleWithingsSleepSync, ); }); - it("imports the new activity / sleep sync routines", () => { - expect(source).toMatch( + it("imports the activity / sleep sync routines", () => { + expect(handlerSource).toMatch( /import\s*\{\s*syncUserActivity\s*\}\s*from\s*["']@\/lib\/withings\/sync-activity["']/, ); - expect(source).toMatch( + expect(handlerSource).toMatch( /import\s*\{\s*syncUserSleep\s*\}\s*from\s*["']@\/lib\/withings\/sync-sleep["']/, ); }); - it("schedules both crons via boss.schedule (allQueues + schedules tables)", () => { - expect(source).toMatch( - /\[WITHINGS_ACTIVITY_QUEUE,\s*WITHINGS_ACTIVITY_CRON\]/, - ); - expect(source).toMatch(/\[WITHINGS_SLEEP_QUEUE,\s*WITHINGS_SLEEP_CRON\]/); + it("schedules both crons", () => { + expect(jobOf(WITHINGS_ACTIVITY_QUEUE).cron).toBe(WITHINGS_ACTIVITY_CRON); + expect(jobOf(WITHINGS_SLEEP_QUEUE).cron).toBe(WITHINGS_SLEEP_CRON); }); it("schedules the ECG catch-net cron at :41 so watch-only accounts pull strips", () => { - // The queue is already in allQueues + createAndWork-bound; W-1 adds the missing - // schedule entry so a payload-less cron tick walks the connection cohort. - expect(source).toMatch( - /WITHINGS_ECG_SYNC_CRON\s*=\s*["']41 \* \* \* \*["']/, - ); - expect(source).toMatch( - /\[WITHINGS_ECG_SYNC_QUEUE,\s*WITHINGS_ECG_SYNC_CRON\]/, - ); + // A payload-less cron tick walks the connection cohort. + expect(WITHINGS_ECG_SYNC_CRON).toBe("41 * * * *"); + expect(jobOf(WITHINGS_ECG_SYNC_QUEUE).cron).toBe(WITHINGS_ECG_SYNC_CRON); }); }); diff --git a/src/lib/jobs/__tests__/workout-insight-queue.test.ts b/src/lib/jobs/__tests__/workout-insight-queue.test.ts index 7af32f112..ce17b76df 100644 --- a/src/lib/jobs/__tests__/workout-insight-queue.test.ts +++ b/src/lib/jobs/__tests__/workout-insight-queue.test.ts @@ -1,40 +1,41 @@ /** - * v1.4.37 dead-queue guard for the `workout-insight-generate` queue. + * Dead-queue guard for the `workout-insight-generate` queue. * * This queue fails open in the worst possible way. Nothing warms it, no cron * touches it, and no read path triggers it — the ONLY thing that ever puts work - * on it is the arrival spine's dispatch. So if the queue name is missing from - * `allQueues`, pg-boss never provisions it, every `boss.send` resolves + * on it is the arrival spine's dispatch. So if the registry does not declare + * the queue, pg-boss never provisions it, every `boss.send` resolves * successfully, every job vanishes, and the only symptom is that no workout * ever gets a paragraph. That is indistinguishable from the feature's own * honest empty state, which is exactly why it needs a structural guard rather * than a behavioural one. * - * Source-text assertions over the registrar, matching the shape of the sibling - * `*-queue.test.ts` guards. + * Reads the worker registry for the queue facts. */ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -const statusRegistrar = readFileSync( - join(__dirname, "..", "reminder", "register-status.ts"), - "utf8", -); +import { handleWorkoutInsightGenerate } from "@/lib/jobs/workout-insight-generate"; +import { + WORKOUT_INSIGHT_GENERATE_CONCURRENCY, + WORKOUT_INSIGHT_GENERATE_QUEUE, +} from "@/lib/jobs/workout-insight-generate-shared"; +import { + groupOf, + jobOf, + loadedHandler, +} from "@/lib/jobs/registry/__tests__/registry-helpers"; describe("workout-insight-generate queue wiring", () => { - it("provisions the queue in the allQueues list", () => { - const allQueues = statusRegistrar.match( - /const allQueues\s*=\s*\[([\s\S]*?)\];/, - ); - expect(allQueues).not.toBeNull(); - expect(allQueues![1]).toMatch(/\bWORKOUT_INSIGHT_GENERATE_QUEUE\b/); + it("provisions the queue in the status group", () => { + expect(groupOf(WORKOUT_INSIGHT_GENERATE_QUEUE).name).toBe("status"); }); - it("binds a createAndWork handler that drains it", () => { - expect(statusRegistrar).toMatch( - /createAndWork[\s\S]{0,300}WORKOUT_INSIGHT_GENERATE_QUEUE[\s\S]{0,300}handleWorkoutInsightGenerate/, + it("binds the handler that drains it", async () => { + expect(await loadedHandler(WORKOUT_INSIGHT_GENERATE_QUEUE)).toBe( + handleWorkoutInsightGenerate, ); }); @@ -42,19 +43,17 @@ describe("workout-insight-generate queue wiring", () => { // This IS the provider path the spine deliberately does not walk. Widening // the concurrency would let one sync's worth of workouts fan out into // parallel completions. - expect(statusRegistrar).toMatch( - /WORKOUT_INSIGHT_GENERATE_QUEUE[\s\S]{0,300}localConcurrency:\s*WORKOUT_INSIGHT_GENERATE_CONCURRENCY/, - ); + expect(jobOf(WORKOUT_INSIGHT_GENERATE_QUEUE).work).toEqual({ + localConcurrency: WORKOUT_INSIGHT_GENERATE_CONCURRENCY, + }); }); it("has no cron schedule — it is dispatched by arrival only", () => { // A cron here would be a standing invitation to regenerate, which is the // saturation surface the design refuses. - // Tolerates the optional third schedule element, so a cron sneaking in - // with send options still trips this. - expect(statusRegistrar).not.toMatch( - /\[\s*WORKOUT_INSIGHT_GENERATE_QUEUE,\s*\w+_CRON\s*[,\]]/, - ); + const job = jobOf(WORKOUT_INSIGHT_GENERATE_QUEUE); + expect(job.cron).toBeUndefined(); + expect(job.scheduleOptions).toBeUndefined(); }); it("is reached from the spine only through the generator-free module", () => { diff --git a/src/lib/jobs/achievement-unlock-sweep-queue.ts b/src/lib/jobs/achievement-unlock-sweep-queue.ts new file mode 100644 index 000000000..4b64470b9 --- /dev/null +++ b/src/lib/jobs/achievement-unlock-sweep-queue.ts @@ -0,0 +1,8 @@ +/** + * The queue facts of `achievement-unlock-sweep.ts`: its name and, where it has them, its + * schedule and concurrency. They live apart from the handler so the worker + * registry and every sender can read them without loading the job. + */ +export const ACHIEVEMENT_UNLOCK_SWEEP_QUEUE = "achievement-unlock-sweep"; + +export const ACHIEVEMENT_UNLOCK_SWEEP_CRON = "25 4 * * *"; diff --git a/src/lib/jobs/achievement-unlock-sweep.ts b/src/lib/jobs/achievement-unlock-sweep.ts index 0a4a12685..190a17189 100644 --- a/src/lib/jobs/achievement-unlock-sweep.ts +++ b/src/lib/jobs/achievement-unlock-sweep.ts @@ -31,10 +31,6 @@ import { resolveModuleMap } from "@/lib/modules/gate"; import { buildAchievementsResult } from "@/lib/gamification/achievements-result"; import { getWorkerPrisma } from "./reminder/shared"; -export const ACHIEVEMENT_UNLOCK_SWEEP_QUEUE = "achievement-unlock-sweep"; - -export const ACHIEVEMENT_UNLOCK_SWEEP_CRON = "25 4 * * *"; - export interface AchievementUnlockSweepPayload { triggeredAt: string; } diff --git a/src/lib/jobs/apple-health-import-queue.ts b/src/lib/jobs/apple-health-import-queue.ts new file mode 100644 index 000000000..e862c9b7d --- /dev/null +++ b/src/lib/jobs/apple-health-import-queue.ts @@ -0,0 +1,36 @@ +/** + * The queue facts of `apple-health-import-worker.ts`: its queue names and + * concurrency. They live apart from the worker so the registry and the upload + * routes can name the queue without loading the parser. + */ +/** + * Queue + cron for the periodic orphan-ImportJob sweep. v1.32.1 + * (issue #588) — `reconcileOrphanImportJobs()` used to run only once, + * at worker boot. `restart: unless-stopped` in `docker-compose.yml` + * DOES bring a crashed/OOM-killed worker back up, so the boot-time + * pass usually catches a dead run — but only when that restart lands + * AFTER the row's heartbeat has already gone stale (30 minutes) or + * pg-boss no longer reports the backing job as live. A fast restart + * lands neither condition true yet, and because the pass runs + * exactly once per boot, a worker that then stays up and healthy + * NEVER re-evaluates that row — it is stuck in `unpacking` / + * `parsing` / `upserting` with "0 rows imported" forever and no + * failure ever surfaced to the status poll. A 15-minute cron closes + * that gap: within two ticks of the heartbeat going stale, the same + * idempotent reconcile flips the row to `failed` with an honest + * reason, so the UI's "Unpacking the archive…" spinner eventually + * turns into a visible, actionable error instead of an indefinite + * silent stall. + */ +export const IMPORT_JOB_RECONCILE_QUEUE = "apple-health-import-reconcile"; + +export const IMPORT_JOB_RECONCILE_CRON = "*/15 * * * *"; + +/** Queue name isolated from revision-1 workers during rolling deployments. */ +export const APPLE_HEALTH_IMPORT_V2_QUEUE = "apple-health-import-v2"; + +/** Queue used by pre-revision-2 binaries; new workers only bridge its backlog. */ +export const APPLE_HEALTH_IMPORT_LEGACY_QUEUE = "apple-health-import"; + +/** Concurrency cap per worker host. */ +export const APPLE_HEALTH_IMPORT_CONCURRENCY = 1; diff --git a/src/lib/jobs/apple-health-import-worker.ts b/src/lib/jobs/apple-health-import-worker.ts index f2cbfe0d9..01a3e3649 100644 --- a/src/lib/jobs/apple-health-import-worker.ts +++ b/src/lib/jobs/apple-health-import-worker.ts @@ -55,10 +55,12 @@ import { type StagingInUse, } from "@/lib/import/apple-health-staging"; import { caughtAs, logCaught } from "@/lib/logging/signal"; +import { HEALTH_CONNECT_IMPORT_KIND } from "@/lib/jobs/health-connect-import-worker"; +import { HEALTH_CONNECT_IMPORT_QUEUE } from "@/lib/jobs/health-connect-import-queue"; import { - HEALTH_CONNECT_IMPORT_KIND, - HEALTH_CONNECT_IMPORT_QUEUE, -} from "@/lib/jobs/health-connect-import-worker"; + APPLE_HEALTH_IMPORT_V2_QUEUE, + APPLE_HEALTH_IMPORT_LEGACY_QUEUE, +} from "./apple-health-import-queue"; /** `ImportJob.kind` of the rows this worker owns. */ export const APPLE_HEALTH_IMPORT_KIND = "apple_health"; @@ -76,28 +78,6 @@ function queuesForKind(kind: string): readonly string[] { : [APPLE_HEALTH_IMPORT_V2_QUEUE, APPLE_HEALTH_IMPORT_LEGACY_QUEUE]; } -/** - * Queue + cron for the periodic orphan-ImportJob sweep. v1.32.1 - * (issue #588) — `reconcileOrphanImportJobs()` used to run only once, - * at worker boot. `restart: unless-stopped` in `docker-compose.yml` - * DOES bring a crashed/OOM-killed worker back up, so the boot-time - * pass usually catches a dead run — but only when that restart lands - * AFTER the row's heartbeat has already gone stale (30 minutes) or - * pg-boss no longer reports the backing job as live. A fast restart - * lands neither condition true yet, and because the pass runs - * exactly once per boot, a worker that then stays up and healthy - * NEVER re-evaluates that row — it is stuck in `unpacking` / - * `parsing` / `upserting` with "0 rows imported" forever and no - * failure ever surfaced to the status poll. A 15-minute cron closes - * that gap: within two ticks of the heartbeat going stale, the same - * idempotent reconcile flips the row to `failed` with an honest - * reason, so the UI's "Unpacking the archive…" spinner eventually - * turns into a visible, actionable error instead of an indefinite - * silent stall. - */ -export const IMPORT_JOB_RECONCILE_QUEUE = "apple-health-import-reconcile"; -export const IMPORT_JOB_RECONCILE_CRON = "*/15 * * * *"; - /** * Cron handler for the periodic orphan-ImportJob sweep. Delegates to * the same `reconcileOrphanImportJobs()` the boot path calls — the @@ -145,12 +125,6 @@ export async function handleImportJobReconcileTick( ); } -/** Queue name isolated from revision-1 workers during rolling deployments. */ -export const APPLE_HEALTH_IMPORT_V2_QUEUE = "apple-health-import-v2"; - -/** Queue used by pre-revision-2 binaries; new workers only bridge its backlog. */ -export const APPLE_HEALTH_IMPORT_LEGACY_QUEUE = "apple-health-import"; - /** Parser semantics carried by every newly-created ImportJob. */ export const APPLE_HEALTH_IMPORT_PARSER_REVISION = 3; @@ -163,9 +137,6 @@ const APPLE_HEALTH_ECG_ARCHIVE_LIMITS = { } as const; const APPLE_HEALTH_ECG_MAX_SAMPLES = 2_000_000; -/** Concurrency cap per worker host. */ -export const APPLE_HEALTH_IMPORT_CONCURRENCY = 1; - /** * (issue #775) Memory preflight for the parse. The streaming pipeline * keeps per-record work off the heap, so what remains resident scales diff --git a/src/lib/jobs/backup-restore-queue.ts b/src/lib/jobs/backup-restore-queue.ts new file mode 100644 index 000000000..7993df548 --- /dev/null +++ b/src/lib/jobs/backup-restore-queue.ts @@ -0,0 +1,6 @@ +/** + * The queue facts of `backup-restore.ts`: its name and, where it has them, its + * schedule and concurrency. They live apart from the handler so the worker + * registry and every sender can read them without loading the job. + */ +export const BACKUP_RESTORE_QUEUE = "backup-restore"; diff --git a/src/lib/jobs/backup-restore.ts b/src/lib/jobs/backup-restore.ts index e17aa8865..ab721743e 100644 --- a/src/lib/jobs/backup-restore.ts +++ b/src/lib/jobs/backup-restore.ts @@ -78,8 +78,7 @@ import { type RestoreFailureCause, } from "@/lib/export/restore-failure-cause"; import { logCaught } from "@/lib/logging/signal"; - -export const BACKUP_RESTORE_QUEUE = "backup-restore"; +import { BACKUP_RESTORE_QUEUE } from "./backup-restore-queue"; /** Stable code of the 409 a second restore of the same account gets. */ export const BACKUP_RESTORE_ACTIVE_CODE = "backup.restore.active"; diff --git a/src/lib/jobs/coach-nudge-queue.ts b/src/lib/jobs/coach-nudge-queue.ts new file mode 100644 index 000000000..bef4d8ac7 --- /dev/null +++ b/src/lib/jobs/coach-nudge-queue.ts @@ -0,0 +1,10 @@ +/** + * The queue facts of `coach-nudge.ts`: its name and, where it has them, its + * schedule and concurrency. They live apart from the handler so the worker + * registry and every sender can read them without loading the job. + */ +/** Pg-boss queue + cron — imported by the reminder worker's bootstrap. */ +export const COACH_NUDGE_QUEUE = "coach-nudge"; + +/** 05:15 Europe/Berlin — after the 04:45–04:55 score crons settled. */ +export const COACH_NUDGE_CRON = "15 5 * * *"; diff --git a/src/lib/jobs/coach-nudge.ts b/src/lib/jobs/coach-nudge.ts index f8fb77cc5..b092ce85e 100644 --- a/src/lib/jobs/coach-nudge.ts +++ b/src/lib/jobs/coach-nudge.ts @@ -121,11 +121,6 @@ const SELF_CONTEXT_AI_SECTIONS = [ "COACH_FOCUS", ] as const satisfies readonly HealthProfileAiSection[]; -/** Pg-boss queue + cron — imported by the reminder worker's bootstrap. */ -export const COACH_NUDGE_QUEUE = "coach-nudge"; -/** 05:15 Europe/Berlin — after the 04:45–04:55 score crons settled. */ -export const COACH_NUDGE_CRON = "15 5 * * *"; - /** * Frequency cap default: one nudge per user per rolling week. v1.16.5 — * the per-user `nudgeFrequency` pref ("weekly" | "biweekly") can widen diff --git a/src/lib/jobs/coach-reminder-sweep-queue.ts b/src/lib/jobs/coach-reminder-sweep-queue.ts new file mode 100644 index 000000000..de3a9240e --- /dev/null +++ b/src/lib/jobs/coach-reminder-sweep-queue.ts @@ -0,0 +1,10 @@ +/** + * The queue facts of `coach-reminder-sweep.ts`: its name and, where it has them, its + * schedule and concurrency. They live apart from the handler so the worker + * registry and every sender can read them without loading the job. + */ +export const COACH_REMINDER_SWEEP_QUEUE = "coach-reminder-sweep"; + +// Daily at 05:20 Europe/Berlin — just after the 05:15 nudge tick so both +// proactive passes run on settled overnight rows. +export const COACH_REMINDER_SWEEP_CRON = "20 5 * * *"; diff --git a/src/lib/jobs/coach-reminder-sweep.ts b/src/lib/jobs/coach-reminder-sweep.ts index dab2112eb..fb47ce4b5 100644 --- a/src/lib/jobs/coach-reminder-sweep.ts +++ b/src/lib/jobs/coach-reminder-sweep.ts @@ -71,11 +71,6 @@ import { surfaceCoachReminders, } from "@/lib/ai/coach/reminder-surface"; -export const COACH_REMINDER_SWEEP_QUEUE = "coach-reminder-sweep"; -// Daily at 05:20 Europe/Berlin — just after the 05:15 nudge tick so both -// proactive passes run on settled overnight rows. -export const COACH_REMINDER_SWEEP_CRON = "20 5 * * *"; - /** Bound the plan-review fan-out per tick so one sweep can't run unbounded. */ const PLAN_REVIEW_BATCH = 200; /** Bound the surfacing fan-out per tick for the same reason. */ diff --git a/src/lib/jobs/compaction-tombstone-purge-queue.ts b/src/lib/jobs/compaction-tombstone-purge-queue.ts new file mode 100644 index 000000000..82b7b775e --- /dev/null +++ b/src/lib/jobs/compaction-tombstone-purge-queue.ts @@ -0,0 +1,6 @@ +/** + * The queue facts of `compaction-tombstone-purge.ts`: its name and, where it has them, its + * schedule, concurrency and send options. They live apart from the job so the + * worker registry and every sender can read them without loading it. + */ +export const COMPACTION_TOMBSTONE_PURGE_QUEUE = "compaction-tombstone-purge"; diff --git a/src/lib/jobs/compaction-tombstone-purge.ts b/src/lib/jobs/compaction-tombstone-purge.ts index 415eee0dc..a43c080da 100644 --- a/src/lib/jobs/compaction-tombstone-purge.ts +++ b/src/lib/jobs/compaction-tombstone-purge.ts @@ -67,8 +67,7 @@ import { findCompactionTombstones, } from "@/lib/measurements/folded-window"; import { prisma as defaultPrisma } from "@/lib/db"; - -export const COMPACTION_TOMBSTONE_PURGE_QUEUE = "compaction-tombstone-purge"; +import { COMPACTION_TOMBSTONE_PURGE_QUEUE } from "./compaction-tombstone-purge-queue"; /** One key for every send, so a queued run absorbs a second boot's send. */ export const COMPACTION_TOMBSTONE_PURGE_SINGLETON_KEY = diff --git a/src/lib/jobs/daily-briefing-queue.ts b/src/lib/jobs/daily-briefing-queue.ts new file mode 100644 index 000000000..95948809e --- /dev/null +++ b/src/lib/jobs/daily-briefing-queue.ts @@ -0,0 +1,9 @@ +/** + * The queue facts of `daily-briefing.ts`: its name and, where it has them, its + * schedule and concurrency. They live apart from the handler so the worker + * registry and every sender can read them without loading the job. + */ +export const DAILY_BRIEFING_QUEUE = "daily-briefing"; + +/** Every 15 minutes; the per-user local-hour gate selects the fallback slot. */ +export const DAILY_BRIEFING_CRON = "*/15 * * * *"; diff --git a/src/lib/jobs/daily-briefing.ts b/src/lib/jobs/daily-briefing.ts index 522cb29c0..f8a50b9d1 100644 --- a/src/lib/jobs/daily-briefing.ts +++ b/src/lib/jobs/daily-briefing.ts @@ -30,10 +30,6 @@ import { } from "@/lib/daily/daily-briefing-push"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; -export const DAILY_BRIEFING_QUEUE = "daily-briefing"; -/** Every 15 minutes; the per-user local-hour gate selects the fallback slot. */ -export const DAILY_BRIEFING_CRON = "*/15 * * * *"; - export interface DailyBriefingTickSummary { candidatesScanned: number; inSlot: number; diff --git a/src/lib/jobs/dawarich-sync-queue.ts b/src/lib/jobs/dawarich-sync-queue.ts new file mode 100644 index 000000000..c597fab3b --- /dev/null +++ b/src/lib/jobs/dawarich-sync-queue.ts @@ -0,0 +1,9 @@ +/** + * The queue facts of `dawarich-sync.ts`: its name and, where it has them, its + * schedule and concurrency. They live apart from the handler so the worker + * registry and every sender can read them without loading the job. + */ +export const DAWARICH_SYNC_QUEUE = "dawarich-sync"; + +/** Daily 03:20 Europe/Berlin, after the night's environment fetch. */ +export const DAWARICH_SYNC_CRON = "20 3 * * *"; diff --git a/src/lib/jobs/dawarich-sync.ts b/src/lib/jobs/dawarich-sync.ts index e856a388a..ebd4bac30 100644 --- a/src/lib/jobs/dawarich-sync.ts +++ b/src/lib/jobs/dawarich-sync.ts @@ -30,11 +30,7 @@ import { import { daysBetweenDateKeys, shiftDateKey } from "@/lib/tz/format"; import { resolveUserTimezone } from "@/lib/tz/resolver"; import { DAWARICH_SYNC_MAX_DAYS } from "@/lib/validations/dawarich"; - -export const DAWARICH_SYNC_QUEUE = "dawarich-sync"; - -/** Daily 03:20 Europe/Berlin, after the night's environment fetch. */ -export const DAWARICH_SYNC_CRON = "20 3 * * *"; +import { DAWARICH_SYNC_QUEUE } from "./dawarich-sync-queue"; /** The nightly window: yesterday and the six days before it. */ const NIGHTLY_WINDOW_DAYS = 7; diff --git a/src/lib/jobs/dense-intraday-hourly-rebuild-queue.ts b/src/lib/jobs/dense-intraday-hourly-rebuild-queue.ts new file mode 100644 index 000000000..0d80fb3db --- /dev/null +++ b/src/lib/jobs/dense-intraday-hourly-rebuild-queue.ts @@ -0,0 +1,14 @@ +/** + * The queue facts of `dense-intraday-hourly-rebuild.ts`: its name and, where it has them, its + * schedule, concurrency and send options. They live apart from the job so the + * worker registry and every sender can read them without loading it. + */ +export const DENSE_INTRADAY_HOURLY_REBUILD_QUEUE = + "dense-intraday-hourly-rebuild"; + +/** + * Serial concurrency — a rebuild walks every folded day for one account + * with a transaction per day; concurrency-1 keeps it from crowding the + * request pool, matching the retention drain's convention. + */ +export const DENSE_INTRADAY_HOURLY_REBUILD_CONCURRENCY = 1; diff --git a/src/lib/jobs/dense-intraday-hourly-rebuild.ts b/src/lib/jobs/dense-intraday-hourly-rebuild.ts index a90de1ea7..14ead6307 100644 --- a/src/lib/jobs/dense-intraday-hourly-rebuild.ts +++ b/src/lib/jobs/dense-intraday-hourly-rebuild.ts @@ -34,16 +34,7 @@ import { DENSE_INTRADAY_RETENTION_TYPES, } from "@/lib/measurements/dense-intraday-retention"; import { runDenseIntradayHourlyRebuild } from "@/lib/measurements/dense-intraday-hourly-rebuild"; - -export const DENSE_INTRADAY_HOURLY_REBUILD_QUEUE = - "dense-intraday-hourly-rebuild"; - -/** - * Serial concurrency — a rebuild walks every folded day for one account - * with a transaction per day; concurrency-1 keeps it from crowding the - * request pool, matching the retention drain's convention. - */ -export const DENSE_INTRADAY_HOURLY_REBUILD_CONCURRENCY = 1; +import { DENSE_INTRADAY_HOURLY_REBUILD_QUEUE } from "./dense-intraday-hourly-rebuild-queue"; /** * Boot-discovery jobs start only after this delay (seconds). The rebuild diff --git a/src/lib/jobs/dense-intraday-retention-queue.ts b/src/lib/jobs/dense-intraday-retention-queue.ts new file mode 100644 index 000000000..e2a6e80da --- /dev/null +++ b/src/lib/jobs/dense-intraday-retention-queue.ts @@ -0,0 +1,14 @@ +/** + * The queue facts of `dense-intraday-retention.ts`: its name and, where it has them, its + * schedule, concurrency and send options. They live apart from the job so the + * worker registry and every sender can read them without loading it. + */ +export const DENSE_INTRADAY_RETENTION_QUEUE = "dense-intraday-retention"; + +/** + * Serial concurrency — the drain walks every out-of-window per-sample + * dense-tier row for a user and writes a transaction per day; + * concurrency-1 keeps it from crowding the request pool, matching the + * mean-consolidation + step-consolidation convention. + */ +export const DENSE_INTRADAY_RETENTION_CONCURRENCY = 1; diff --git a/src/lib/jobs/dense-intraday-retention.ts b/src/lib/jobs/dense-intraday-retention.ts index 7cbcdfad1..c807020a9 100644 --- a/src/lib/jobs/dense-intraday-retention.ts +++ b/src/lib/jobs/dense-intraday-retention.ts @@ -27,16 +27,7 @@ import { DENSE_INTRADAY_RETENTION_DAYS, DENSE_INTRADAY_FOLD_SOURCES, } from "@/lib/measurements/dense-intraday-retention"; - -export const DENSE_INTRADAY_RETENTION_QUEUE = "dense-intraday-retention"; - -/** - * Serial concurrency — the drain walks every out-of-window per-sample - * dense-tier row for a user and writes a transaction per day; - * concurrency-1 keeps it from crowding the request pool, matching the - * mean-consolidation + step-consolidation convention. - */ -export const DENSE_INTRADAY_RETENTION_CONCURRENCY = 1; +import { DENSE_INTRADAY_RETENTION_QUEUE } from "./dense-intraday-retention-queue"; /** * Daily at 03:50 Europe/Berlin — inside the existing 03:xx maintenance diff --git a/src/lib/jobs/document-ai-run-queue.ts b/src/lib/jobs/document-ai-run-queue.ts new file mode 100644 index 000000000..53583d010 --- /dev/null +++ b/src/lib/jobs/document-ai-run-queue.ts @@ -0,0 +1,9 @@ +/** + * The queue facts of `document-ai-run.ts`: its name and, where it has them, its + * schedule and concurrency. They live apart from the handler so the worker + * registry and every sender can read them without loading the job. + */ +export const DOCUMENT_AI_RUN_QUEUE = "document-ai-run"; + +/** Two reads at a time: provider calls, kept off the request pool. */ +export const DOCUMENT_AI_RUN_CONCURRENCY = 2; diff --git a/src/lib/jobs/document-ai-run-reaper-queue.ts b/src/lib/jobs/document-ai-run-reaper-queue.ts new file mode 100644 index 000000000..143869c04 --- /dev/null +++ b/src/lib/jobs/document-ai-run-reaper-queue.ts @@ -0,0 +1,9 @@ +/** + * The queue facts of `document-ai-run-reaper.ts`: its name and, where it has them, its + * schedule and concurrency. They live apart from the job so the worker + * registry and every sender can read them without loading it. + */ +export const DOCUMENT_AI_RUN_REAPER_QUEUE = "document-ai-run-reaper"; + +/** Every five minutes: a queued run fails within twenty minutes at worst. */ +export const DOCUMENT_AI_RUN_REAPER_CRON = "*/5 * * * *"; diff --git a/src/lib/jobs/document-ai-run-reaper.ts b/src/lib/jobs/document-ai-run-reaper.ts index f2e79eb40..ba6f8bd65 100644 --- a/src/lib/jobs/document-ai-run-reaper.ts +++ b/src/lib/jobs/document-ai-run-reaper.ts @@ -15,10 +15,6 @@ import { reapAiRuns } from "@/lib/documents/ai-runs/store"; import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome"; import { withBackgroundEvent } from "@/lib/logging/background"; -export const DOCUMENT_AI_RUN_REAPER_QUEUE = "document-ai-run-reaper"; -/** Every five minutes: a queued run fails within twenty minutes at worst. */ -export const DOCUMENT_AI_RUN_REAPER_CRON = "*/5 * * * *"; - export interface DocumentAiRunReaperPayload { triggeredAt?: string; } diff --git a/src/lib/jobs/document-ai-run.ts b/src/lib/jobs/document-ai-run.ts index 2000054b4..6d9f25a52 100644 --- a/src/lib/jobs/document-ai-run.ts +++ b/src/lib/jobs/document-ai-run.ts @@ -97,11 +97,7 @@ import { executeSupplementLabelExtraction, requireSupplementLabelProvider, } from "@/lib/medications/ingredients/label-extract"; - -export const DOCUMENT_AI_RUN_QUEUE = "document-ai-run"; - -/** Two reads at a time: provider calls, kept off the request pool. */ -export const DOCUMENT_AI_RUN_CONCURRENCY = 2; +import { DOCUMENT_AI_RUN_QUEUE } from "./document-ai-run-queue"; /** * The job's expiry: one hour. Three model calls at the longest response diff --git a/src/lib/jobs/document-content-index-backfill-queue.ts b/src/lib/jobs/document-content-index-backfill-queue.ts new file mode 100644 index 000000000..513072f8d --- /dev/null +++ b/src/lib/jobs/document-content-index-backfill-queue.ts @@ -0,0 +1,9 @@ +/** + * The queue facts of `document-content-index-backfill.ts`: its name and, where it has them, its + * schedule and concurrency. They live apart from the handler so the worker + * registry and every sender can read them without loading the job. + */ +export const CONTENT_INDEX_BACKFILL_QUEUE = "document-content-index-backfill"; + +/** Serial concurrency — provider calls, kept off the request pool. */ +export const CONTENT_INDEX_BACKFILL_CONCURRENCY = 1; diff --git a/src/lib/jobs/document-content-index-backfill.ts b/src/lib/jobs/document-content-index-backfill.ts index f355cb7d3..2402e7149 100644 --- a/src/lib/jobs/document-content-index-backfill.ts +++ b/src/lib/jobs/document-content-index-backfill.ts @@ -55,11 +55,7 @@ import { transcribeDocument } from "@/lib/documents/describe"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order"; import { annotate } from "@/lib/logging/context"; - -export const CONTENT_INDEX_BACKFILL_QUEUE = "document-content-index-backfill"; - -/** Serial concurrency — provider calls, kept off the request pool. */ -export const CONTENT_INDEX_BACKFILL_CONCURRENCY = 1; +import { CONTENT_INDEX_BACKFILL_QUEUE } from "./document-content-index-backfill-queue"; /** Discovery page size (id-only; blobs are loaded one at a time). */ const PAGE_SIZE = 50; diff --git a/src/lib/jobs/document-index-queue.ts b/src/lib/jobs/document-index-queue.ts new file mode 100644 index 000000000..caf1d936d --- /dev/null +++ b/src/lib/jobs/document-index-queue.ts @@ -0,0 +1,9 @@ +/** + * The queue facts of `document-index.ts`: its name and, where it has them, its + * schedule and concurrency. They live apart from the handler so the worker + * registry and every sender can read them without loading the job. + */ +export const DOCUMENT_INDEX_QUEUE = "document-index"; + +/** Serial concurrency — provider calls + PDF parse, kept off the request pool. */ +export const DOCUMENT_INDEX_CONCURRENCY = 1; diff --git a/src/lib/jobs/document-index.ts b/src/lib/jobs/document-index.ts index abca1d45a..4cfacec37 100644 --- a/src/lib/jobs/document-index.ts +++ b/src/lib/jobs/document-index.ts @@ -17,11 +17,7 @@ import { indexDocumentContent } from "@/lib/documents/index-document"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { annotate } from "@/lib/logging/context"; import { caughtAs, logCaught } from "@/lib/logging/signal"; - -export const DOCUMENT_INDEX_QUEUE = "document-index"; - -/** Serial concurrency — provider calls + PDF parse, kept off the request pool. */ -export const DOCUMENT_INDEX_CONCURRENCY = 1; +import { DOCUMENT_INDEX_QUEUE } from "./document-index-queue"; export interface DocumentIndexPayload { userId: string; diff --git a/src/lib/jobs/document-summary-catchup-queue.ts b/src/lib/jobs/document-summary-catchup-queue.ts new file mode 100644 index 000000000..3fa82c31f --- /dev/null +++ b/src/lib/jobs/document-summary-catchup-queue.ts @@ -0,0 +1,9 @@ +/** + * The queue facts of `document-summary-catchup.ts`: its name and, where it has them, its + * schedule and concurrency. They live apart from the handler so the worker + * registry and every sender can read them without loading the job. + */ +export const DOCUMENT_SUMMARY_CATCHUP_QUEUE = "document-summary-catchup"; + +/** Serial concurrency — cheap discovery + fan-out enqueues. */ +export const DOCUMENT_SUMMARY_CATCHUP_CONCURRENCY = 1; diff --git a/src/lib/jobs/document-summary-catchup.ts b/src/lib/jobs/document-summary-catchup.ts index 3cb5c191a..9d8ce97d3 100644 --- a/src/lib/jobs/document-summary-catchup.ts +++ b/src/lib/jobs/document-summary-catchup.ts @@ -40,11 +40,7 @@ import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { enqueueDocumentSummary } from "@/lib/jobs/document-summary"; import { annotate } from "@/lib/logging/context"; import { logCaught } from "@/lib/logging/signal"; - -export const DOCUMENT_SUMMARY_CATCHUP_QUEUE = "document-summary-catchup"; - -/** Serial concurrency — cheap discovery + fan-out enqueues. */ -export const DOCUMENT_SUMMARY_CATCHUP_CONCURRENCY = 1; +import { DOCUMENT_SUMMARY_CATCHUP_QUEUE } from "./document-summary-catchup-queue"; /** Discovery page size (id-only). */ const PAGE_SIZE = 100; diff --git a/src/lib/jobs/document-summary-queue.ts b/src/lib/jobs/document-summary-queue.ts new file mode 100644 index 000000000..c509924d7 --- /dev/null +++ b/src/lib/jobs/document-summary-queue.ts @@ -0,0 +1,9 @@ +/** + * The queue facts of `document-summary.ts`: its name and, where it has them, its + * schedule and concurrency. They live apart from the handler so the worker + * registry and every sender can read them without loading the job. + */ +export const DOCUMENT_SUMMARY_QUEUE = "document-summary"; + +/** Serial concurrency — provider calls, kept off the request pool. */ +export const DOCUMENT_SUMMARY_CONCURRENCY = 1; diff --git a/src/lib/jobs/document-summary-reaper-queue.ts b/src/lib/jobs/document-summary-reaper-queue.ts new file mode 100644 index 000000000..0eb2012db --- /dev/null +++ b/src/lib/jobs/document-summary-reaper-queue.ts @@ -0,0 +1,9 @@ +/** + * The queue facts of `document-summary-reaper.ts`: its name and, where it has them, its + * schedule and concurrency. They live apart from the job so the worker + * registry and every sender can read them without loading it. + */ +export const DOCUMENT_SUMMARY_REAPER_QUEUE = "document-summary-reaper"; + +/** Hourly at :50 — off the maintenance herd's minute marks. */ +export const DOCUMENT_SUMMARY_REAPER_CRON = "50 * * * *"; diff --git a/src/lib/jobs/document-summary-reaper.ts b/src/lib/jobs/document-summary-reaper.ts index f3e7b9362..775a90926 100644 --- a/src/lib/jobs/document-summary-reaper.ts +++ b/src/lib/jobs/document-summary-reaper.ts @@ -30,10 +30,6 @@ import { getWorkerPrisma } from "@/lib/jobs/reminder/shared"; import { SUMMARY_PENDING_TTL_MS } from "@/lib/documents/store"; import type { PrismaClient } from "@/generated/prisma/client"; -export const DOCUMENT_SUMMARY_REAPER_QUEUE = "document-summary-reaper"; -/** Hourly at :50 — off the maintenance herd's minute marks. */ -export const DOCUMENT_SUMMARY_REAPER_CRON = "50 * * * *"; - export interface DocumentSummaryReaperPayload { triggeredAt?: string; } diff --git a/src/lib/jobs/document-summary.ts b/src/lib/jobs/document-summary.ts index de889748f..dfebd34e6 100644 --- a/src/lib/jobs/document-summary.ts +++ b/src/lib/jobs/document-summary.ts @@ -49,8 +49,7 @@ import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { annotate } from "@/lib/logging/context"; import type { DocumentSummaryState } from "@/generated/prisma/client"; import { logCaught } from "@/lib/logging/signal"; - -export const DOCUMENT_SUMMARY_QUEUE = "document-summary"; +import { DOCUMENT_SUMMARY_QUEUE } from "./document-summary-queue"; /** * Record what became of this document's summary. Never touches a READY row — @@ -73,9 +72,6 @@ async function markSummaryState( }); } -/** Serial concurrency — provider calls, kept off the request pool. */ -export const DOCUMENT_SUMMARY_CONCURRENCY = 1; - export interface DocumentSummaryPayload { userId: string; documentId: string; diff --git a/src/lib/jobs/document-thumbnail-backfill-queue.ts b/src/lib/jobs/document-thumbnail-backfill-queue.ts new file mode 100644 index 000000000..d8c086432 --- /dev/null +++ b/src/lib/jobs/document-thumbnail-backfill-queue.ts @@ -0,0 +1,9 @@ +/** + * The queue facts of `document-thumbnail-backfill.ts`: its name and, where it has them, its + * schedule and concurrency. They live apart from the job so the worker + * registry and every sender can read them without loading it. + */ +export const DOCUMENT_THUMBNAIL_BACKFILL_QUEUE = "document-thumbnail-backfill"; + +/** Serial concurrency — cheap discovery + fan-out enqueues. */ +export const DOCUMENT_THUMBNAIL_BACKFILL_CONCURRENCY = 1; diff --git a/src/lib/jobs/document-thumbnail-backfill.ts b/src/lib/jobs/document-thumbnail-backfill.ts index e09568c6b..bdd373642 100644 --- a/src/lib/jobs/document-thumbnail-backfill.ts +++ b/src/lib/jobs/document-thumbnail-backfill.ts @@ -30,11 +30,7 @@ import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { enqueueDocumentThumbnail } from "@/lib/jobs/document-thumbnail"; import { annotate } from "@/lib/logging/context"; import { logCaught } from "@/lib/logging/signal"; - -export const DOCUMENT_THUMBNAIL_BACKFILL_QUEUE = "document-thumbnail-backfill"; - -/** Serial concurrency — cheap discovery + fan-out enqueues. */ -export const DOCUMENT_THUMBNAIL_BACKFILL_CONCURRENCY = 1; +import { DOCUMENT_THUMBNAIL_BACKFILL_QUEUE } from "./document-thumbnail-backfill-queue"; /** Discovery page size (id-only). */ const PAGE_SIZE = 100; diff --git a/src/lib/jobs/document-thumbnail-queue.ts b/src/lib/jobs/document-thumbnail-queue.ts new file mode 100644 index 000000000..312b7269f --- /dev/null +++ b/src/lib/jobs/document-thumbnail-queue.ts @@ -0,0 +1,9 @@ +/** + * The queue facts of `document-thumbnail.ts`: its name and, where it has them, its + * schedule and concurrency. They live apart from the job so the worker + * registry and every sender can read them without loading it. + */ +export const DOCUMENT_THUMBNAIL_QUEUE = "document-thumbnail"; + +/** Serial concurrency — canvas/pdfjs decode, kept off the request pool. */ +export const DOCUMENT_THUMBNAIL_CONCURRENCY = 1; diff --git a/src/lib/jobs/document-thumbnail.ts b/src/lib/jobs/document-thumbnail.ts index f593ab760..4195820ee 100644 --- a/src/lib/jobs/document-thumbnail.ts +++ b/src/lib/jobs/document-thumbnail.ts @@ -34,11 +34,7 @@ import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { withBackgroundEvent } from "@/lib/logging/background"; import { annotate } from "@/lib/logging/context"; import { logCaught } from "@/lib/logging/signal"; - -export const DOCUMENT_THUMBNAIL_QUEUE = "document-thumbnail"; - -/** Serial concurrency — canvas/pdfjs decode, kept off the request pool. */ -export const DOCUMENT_THUMBNAIL_CONCURRENCY = 1; +import { DOCUMENT_THUMBNAIL_QUEUE } from "./document-thumbnail-queue"; export interface DocumentThumbnailPayload { userId: string; diff --git a/src/lib/jobs/drain-cumulative-queue.ts b/src/lib/jobs/drain-cumulative-queue.ts new file mode 100644 index 000000000..352049c6c --- /dev/null +++ b/src/lib/jobs/drain-cumulative-queue.ts @@ -0,0 +1,20 @@ +/** + * The nightly drain of per-sample cumulative rows: its queue, cron and + * payload. It collapses each user × cumulative type × day into one `stats:` + * row, and the same tick folds the daily-mean and dense intra-day retention + * passes (`reminder/rollup-handlers.ts`). 03:45 sits between the 03:15 + * audit-log cleanup and the 04:00 feedback aggregator; the 36-hour grace + * window keeps today and the trailing watch-sync window raw. + */ +export const DRAIN_CUMULATIVE_QUEUE = "drain-per-sample-cumulative"; + +export const DRAIN_CUMULATIVE_CRON = "45 3 * * *"; + +export interface DrainCumulativePayload { + triggeredAt?: string; + /** + * How many times this tick has already handed itself on. Absent on the + * cron's own job. See `nextDrainContinuation`. + */ + continuation?: number; +} diff --git a/src/lib/jobs/encryption-key-rotate-queue.ts b/src/lib/jobs/encryption-key-rotate-queue.ts new file mode 100644 index 000000000..40e686ca1 --- /dev/null +++ b/src/lib/jobs/encryption-key-rotate-queue.ts @@ -0,0 +1,8 @@ +/** + * The queue facts of `encryption-key-rotate.ts`: its name and, where it has them, its + * schedule and concurrency. They live apart from the job so the worker + * registry and every sender can read them without loading it. + */ +export const ENCRYPTION_KEY_ROTATE_QUEUE = "encryption-key-rotate"; + +export const ENCRYPTION_KEY_ROTATE_CONCURRENCY = 1; diff --git a/src/lib/jobs/encryption-key-rotate.ts b/src/lib/jobs/encryption-key-rotate.ts index cf1c52250..ccde5044a 100644 --- a/src/lib/jobs/encryption-key-rotate.ts +++ b/src/lib/jobs/encryption-key-rotate.ts @@ -36,9 +36,6 @@ import { retireCanariesWithoutData, type CanaryClient, } from "@/lib/crypto/canary"; - -export const ENCRYPTION_KEY_ROTATE_QUEUE = "encryption-key-rotate"; -export const ENCRYPTION_KEY_ROTATE_CONCURRENCY = 1; /** Fixed key so duplicate admin triggers coalesce into one queued run. */ export const ENCRYPTION_KEY_ROTATE_SINGLETON = "encryption-key-rotate"; diff --git a/src/lib/jobs/google-health-backfill-queue.ts b/src/lib/jobs/google-health-backfill-queue.ts new file mode 100644 index 000000000..26856d461 --- /dev/null +++ b/src/lib/jobs/google-health-backfill-queue.ts @@ -0,0 +1,13 @@ +/** + * The queue facts of `google-health-backfill.ts`: its name and, where it has them, its + * schedule and concurrency. They live apart from the handler so the worker + * registry and every sender can read them without loading the job. + */ +export const GOOGLE_HEALTH_BACKFILL_QUEUE = "google-health-backfill"; + +/** + * Serial concurrency — a backfill walks years of history for one account and is + * rate-bounded by Google's per-app quota; concurrency-1 keeps it from crowding + * the request pool, matching `FITBIT_BACKFILL_CONCURRENCY`. + */ +export const GOOGLE_HEALTH_BACKFILL_CONCURRENCY = 1; diff --git a/src/lib/jobs/google-health-backfill.ts b/src/lib/jobs/google-health-backfill.ts index d070872de..127347574 100644 --- a/src/lib/jobs/google-health-backfill.ts +++ b/src/lib/jobs/google-health-backfill.ts @@ -1,7 +1,7 @@ /** * v1.26.0 — pg-boss queue + boot-time self-converging backfill for newly * connected Google Health accounts. Modelled on the Fitbit / WHOOP backfill - * (`src/lib/jobs/fitbit-backfill.ts`): a discovery query enqueues one job per + * (`src/lib/fitbit/jobs/backfill.ts`): a discovery query enqueues one job per * connection that has NOT yet been backfilled, and the pass is idempotent * across reboots (the predicate `backfill_completed_at IS NULL` drops a * connection once its backfill finishes). @@ -40,19 +40,11 @@ import { googleHealthSliceBudget, type GoogleHealthSliceStopReason, runWithGoogleHealthStop, -} from "@/lib/google-health/client"; +} from "@/lib/google-health/budget"; import { reportJobProgress } from "@/lib/jobs/job-observer"; import { LANE_YIELD_DELAY_SECONDS, laneWanted } from "@/lib/jobs/heavy-lane"; import { GOOGLE_HEALTH_COLLECTION_KEYS } from "@/lib/google-health/collection-keys"; - -export const GOOGLE_HEALTH_BACKFILL_QUEUE = "google-health-backfill"; - -/** - * Serial concurrency — a backfill walks years of history for one account and is - * rate-bounded by Google's per-app quota; concurrency-1 keeps it from crowding - * the request pool, matching `FITBIT_BACKFILL_CONCURRENCY`. - */ -export const GOOGLE_HEALTH_BACKFILL_CONCURRENCY = 1; +import { GOOGLE_HEALTH_BACKFILL_QUEUE } from "./google-health-backfill-queue"; /** * One slice of a backfill: how long it walks and how many pages it reads diff --git a/src/lib/jobs/google-health-sleep-repair-queue.ts b/src/lib/jobs/google-health-sleep-repair-queue.ts new file mode 100644 index 000000000..4f83dcf38 --- /dev/null +++ b/src/lib/jobs/google-health-sleep-repair-queue.ts @@ -0,0 +1,13 @@ +/** + * The queue facts of `google-health-sleep-repair.ts`: its name and, where it has them, its + * schedule and concurrency. They live apart from the handler so the worker + * registry and every sender can read them without loading the job. + */ +export const GOOGLE_HEALTH_SLEEP_REPAIR_QUEUE = "google-health-sleep-repair"; + +/** + * Serial concurrency — a repair walks the full sleep history for one account + * and is rate-bounded by Google's per-app quota; concurrency-1 keeps it from + * crowding the request pool, matching `GOOGLE_HEALTH_BACKFILL_CONCURRENCY`. + */ +export const GOOGLE_HEALTH_SLEEP_REPAIR_CONCURRENCY = 1; diff --git a/src/lib/jobs/google-health-sleep-repair.ts b/src/lib/jobs/google-health-sleep-repair.ts index 8c01adc9b..e4fe7dedf 100644 --- a/src/lib/jobs/google-health-sleep-repair.ts +++ b/src/lib/jobs/google-health-sleep-repair.ts @@ -37,15 +37,7 @@ import { GOOGLE_HEALTH_SYNC_LOCK_WAIT_MS, withGoogleHealthSyncLock, } from "@/lib/google-health/sync-lock"; - -export const GOOGLE_HEALTH_SLEEP_REPAIR_QUEUE = "google-health-sleep-repair"; - -/** - * Serial concurrency — a repair walks the full sleep history for one account - * and is rate-bounded by Google's per-app quota; concurrency-1 keeps it from - * crowding the request pool, matching `GOOGLE_HEALTH_BACKFILL_CONCURRENCY`. - */ -export const GOOGLE_HEALTH_SLEEP_REPAIR_CONCURRENCY = 1; +import { GOOGLE_HEALTH_SLEEP_REPAIR_QUEUE } from "./google-health-sleep-repair-queue"; export interface GoogleHealthSleepRepairPayload { userId: string; diff --git a/src/lib/jobs/health-connect-import-queue.ts b/src/lib/jobs/health-connect-import-queue.ts new file mode 100644 index 000000000..55b3f3c1b --- /dev/null +++ b/src/lib/jobs/health-connect-import-queue.ts @@ -0,0 +1,9 @@ +/** + * The queue facts of `health-connect-import-worker.ts`: its queue names and + * concurrency. They live apart from the worker so the registry and the upload + * routes can name the queue without loading the parser. + */ +export const HEALTH_CONNECT_IMPORT_QUEUE = "health-connect-import"; + +/** One import at a time per worker: the SQLite read and the writes are heavy. */ +export const HEALTH_CONNECT_IMPORT_CONCURRENCY = 1; diff --git a/src/lib/jobs/health-connect-import-worker.ts b/src/lib/jobs/health-connect-import-worker.ts index 08850c72a..a989b56c5 100644 --- a/src/lib/jobs/health-connect-import-worker.ts +++ b/src/lib/jobs/health-connect-import-worker.ts @@ -33,8 +33,6 @@ import { annotate } from "@/lib/logging/context"; import { withBackgroundEvent } from "@/lib/logging/background"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; -export const HEALTH_CONNECT_IMPORT_QUEUE = "health-connect-import"; - /** `ImportJob.kind` of the rows this worker owns. */ export const HEALTH_CONNECT_IMPORT_KIND = "health_connect"; @@ -45,9 +43,6 @@ export const HEALTH_CONNECT_IMPORT_KIND = "health_connect"; */ export const HEALTH_CONNECT_IMPORT_PARSER_REVISION = 1; -/** One import at a time per worker: the SQLite read and the writes are heavy. */ -export const HEALTH_CONNECT_IMPORT_CONCURRENCY = 1; - /** Same shape and reasoning as `APPLE_HEALTH_IMPORT_SEND_OPTIONS`. */ export const HEALTH_CONNECT_IMPORT_SEND_OPTIONS = { retryLimit: 0, diff --git a/src/lib/jobs/insight-pregenerate-shared.ts b/src/lib/jobs/insight-pregenerate-shared.ts index 136d9353a..f15a9e605 100644 --- a/src/lib/jobs/insight-pregenerate-shared.ts +++ b/src/lib/jobs/insight-pregenerate-shared.ts @@ -249,3 +249,10 @@ export async function enqueuePregenerateFailureRetry(payload: { // catch-net, exactly the pre-retry behaviour. } } + +/** + * 04:30 Europe/Berlin. Slots between the cumulative drain (03:30) and + * the feedback aggregator (04:00) maintenance window's tail so the + * LLM-bound pass doesn't contend with the SQL-bound nightly folds. + */ +export const INSIGHT_PREGENERATE_CRON = "30 4 * * *"; diff --git a/src/lib/jobs/insight-pregenerate.ts b/src/lib/jobs/insight-pregenerate.ts index 509551b51..b8a6ce6b8 100644 --- a/src/lib/jobs/insight-pregenerate.ts +++ b/src/lib/jobs/insight-pregenerate.ts @@ -91,13 +91,6 @@ import { export { INSIGHT_PREGENERATE_QUEUE, enqueueForceWarm }; export type { InsightPregeneratePayload }; -/** - * 04:30 Europe/Berlin. Slots between the cumulative drain (03:30) and - * the feedback aggregator (04:00) maintenance window's tail so the - * LLM-bound pass doesn't contend with the SQL-bound nightly folds. - */ -export const INSIGHT_PREGENERATE_CRON = "30 4 * * *"; - /** Stale-cache threshold for the nightly discovery pass. * * The cron runs every 24 h and the advisor cache lives 24 h — so ANY diff --git a/src/lib/jobs/insight-status-generate-queue.ts b/src/lib/jobs/insight-status-generate-queue.ts new file mode 100644 index 000000000..bee53259d --- /dev/null +++ b/src/lib/jobs/insight-status-generate-queue.ts @@ -0,0 +1,13 @@ +/** + * The queue facts of `insight-status-generate-shared.ts`: its name and, where it has them, its + * schedule, concurrency and send options. They live apart from the job so the + * worker registry and every sender can read them without loading it. + */ +export const INSIGHT_STATUS_GENERATE_QUEUE = "insight-status-generate"; + +/** + * Low concurrency keeps the on-demand warm pass from saturating the + * Prisma pool or fanning out an unbounded number of concurrent provider + * calls when several cards request generation on the same first visit. + */ +export const INSIGHT_STATUS_GENERATE_CONCURRENCY = 2; diff --git a/src/lib/jobs/insight-status-generate-shared.ts b/src/lib/jobs/insight-status-generate-shared.ts index 5296732d1..e448872d9 100644 --- a/src/lib/jobs/insight-status-generate-shared.ts +++ b/src/lib/jobs/insight-status-generate-shared.ts @@ -21,15 +21,7 @@ import { type ProviderWorkAuthority, } from "@/lib/sharing/provider-work-authority"; import { logCaught } from "@/lib/logging/signal"; - -export const INSIGHT_STATUS_GENERATE_QUEUE = "insight-status-generate"; - -/** - * Low concurrency keeps the on-demand warm pass from saturating the - * Prisma pool or fanning out an unbounded number of concurrent provider - * calls when several cards request generation on the same first visit. - */ -export const INSIGHT_STATUS_GENERATE_CONCURRENCY = 2; +import { INSIGHT_STATUS_GENERATE_QUEUE } from "./insight-status-generate-queue"; /** * The seven per-metric status scopes the queue can generate. Mirrors the diff --git a/src/lib/jobs/insight-status-generate.ts b/src/lib/jobs/insight-status-generate.ts index 80118c473..33b525017 100644 --- a/src/lib/jobs/insight-status-generate.ts +++ b/src/lib/jobs/insight-status-generate.ts @@ -49,13 +49,15 @@ import { } from "@/lib/insights/derived/derived-assessment-ai"; import { isDerivedMetricId } from "@/lib/insights/derived/registry"; import { - INSIGHT_STATUS_GENERATE_QUEUE, - INSIGHT_STATUS_GENERATE_CONCURRENCY, INSIGHT_STATUS_METRICS, enqueueStatusGeneration, type InsightStatusMetric, type InsightStatusGeneratePayload, } from "@/lib/jobs/insight-status-generate-shared"; +import { + INSIGHT_STATUS_GENERATE_QUEUE, + INSIGHT_STATUS_GENERATE_CONCURRENCY, +} from "@/lib/jobs/insight-status-generate-queue"; import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; import { mayDispatchProviderWork, diff --git a/src/lib/jobs/intake-auto-skip-queue.ts b/src/lib/jobs/intake-auto-skip-queue.ts new file mode 100644 index 000000000..6488f849d --- /dev/null +++ b/src/lib/jobs/intake-auto-skip-queue.ts @@ -0,0 +1,13 @@ +/** + * The queue facts of `intake-auto-skip.ts`: its name and, where it has them, its + * schedule and concurrency. They live apart from the handler so the worker + * registry and every sender can read them without loading the job. + */ +export const INTAKE_AUTO_SKIP_QUEUE = "intake-auto-skip"; + +/** + * Cron schedule. Hourly at :05 so the tick doesn't collide with the + * top-of-the-hour reminder-check (:00) or the rollup fold (:30) + * cadences. Europe/Berlin matches the rest of the worker's schedules. + */ +export const INTAKE_AUTO_SKIP_CRON = "5 * * * *"; diff --git a/src/lib/jobs/intake-auto-skip.ts b/src/lib/jobs/intake-auto-skip.ts index 0220bc51e..db63b19c0 100644 --- a/src/lib/jobs/intake-auto-skip.ts +++ b/src/lib/jobs/intake-auto-skip.ts @@ -73,15 +73,6 @@ import { } from "@/lib/medications/scheduling/live-era"; import { INTAKE_AUTO_TAKE_LOOKBACK_MS } from "@/lib/jobs/intake-auto-take"; -export const INTAKE_AUTO_SKIP_QUEUE = "intake-auto-skip"; - -/** - * Cron schedule. Hourly at :05 so the tick doesn't collide with the - * top-of-the-hour reminder-check (:00) or the rollup fold (:30) - * cadences. Europe/Berlin matches the rest of the worker's schedules. - */ -export const INTAKE_AUTO_SKIP_CRON = "5 * * * *"; - /** * Grace floor before auto-skip kicks in. 24 h matches the spec and * the user's real-world cadence (late entries up to a full day after diff --git a/src/lib/jobs/intake-auto-take-queue.ts b/src/lib/jobs/intake-auto-take-queue.ts new file mode 100644 index 000000000..8572b837b --- /dev/null +++ b/src/lib/jobs/intake-auto-take-queue.ts @@ -0,0 +1,12 @@ +/** + * The queue facts of `intake-auto-take.ts`: its name and, where it has them, its + * schedule and concurrency. They live apart from the handler so the worker + * registry and every sender can read them without loading the job. + */ +export const INTAKE_AUTO_TAKE_QUEUE = "intake-auto-take"; + +/** + * Every 15 minutes, off the reminder tick (:00/:15/:30/:45) and the hourly + * auto-miss pass (:05). A 19:00 dose reads taken by 19:10 at the latest. + */ +export const INTAKE_AUTO_TAKE_CRON = "10,25,40,55 * * * *"; diff --git a/src/lib/jobs/intake-auto-take.ts b/src/lib/jobs/intake-auto-take.ts index e26a42dca..16265f1d2 100644 --- a/src/lib/jobs/intake-auto-take.ts +++ b/src/lib/jobs/intake-auto-take.ts @@ -98,14 +98,6 @@ export { type SlotDecision, } from "@/lib/medications/auto-take"; -export const INTAKE_AUTO_TAKE_QUEUE = "intake-auto-take"; - -/** - * Every 15 minutes, off the reminder tick (:00/:15/:30/:45) and the hourly - * auto-miss pass (:05). A 19:00 dose reads taken by 19:10 at the latest. - */ -export const INTAKE_AUTO_TAKE_CRON = "10,25,40,55 * * * *"; - const MINUTE_MS = 60 * 1000; const HOUR_MS = 60 * MINUTE_MS; const DAY_MS = 24 * HOUR_MS; diff --git a/src/lib/jobs/integration-backfill-drain.ts b/src/lib/jobs/integration-backfill-drain.ts index dbce7a548..033002cb6 100644 --- a/src/lib/jobs/integration-backfill-drain.ts +++ b/src/lib/jobs/integration-backfill-drain.ts @@ -17,10 +17,10 @@ import type { Job } from "pg-boss"; import { runWhoopBackfillForUser } from "@/lib/jobs/whoop-backfill"; -import { runFitbitBackfillForUser } from "@/lib/jobs/fitbit-backfill"; +import { runFitbitBackfillForUser } from "@/lib/fitbit/jobs/backfill"; import { runGoogleHealthBackfillForUser } from "@/lib/jobs/google-health-backfill"; import { runGoogleHealthSleepRepairForUser } from "@/lib/jobs/google-health-sleep-repair"; -import { runFitbitSleepRepairForUser } from "@/lib/jobs/fitbit-sleep-repair"; +import { runFitbitSleepRepairForUser } from "@/lib/fitbit/jobs/sleep-repair"; import { runStravaBackfillForUser } from "@/lib/jobs/strava-backfill"; import { runOuraBackfillForUser } from "@/lib/jobs/oura-backfill"; import { runSleepTimelineBackfillForUser } from "@/lib/jobs/sleep-timeline-backfill"; diff --git a/src/lib/jobs/integration-backfill-forward.ts b/src/lib/jobs/integration-backfill-forward.ts new file mode 100644 index 000000000..054fc111b --- /dev/null +++ b/src/lib/jobs/integration-backfill-forward.ts @@ -0,0 +1,27 @@ +/** + * The handler of every full-history source queue (`whoop-backfill`, + * `strava-backfill`, the sleep repairs, ...): it forwards each job to the + * shared admission queue, where the import itself runs in the one + * cross-provider lane. The source queue keeps its per-connection singleton + * key, which is what boot discovery re-offers against. + */ +import type { Job, PgBoss } from "pg-boss"; + +import { + enqueueIntegrationBackfillAdmission, + type IntegrationBackfillData, + type IntegrationBackfillKind, +} from "@/lib/jobs/integration-backfill-admission"; +import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome"; + +export function forwardToBackfillAdmission( + boss: PgBoss, + kind: IntegrationBackfillKind, +): (jobs: Job[]) => Promise { + return async (jobs) => { + for (const job of jobs) { + await enqueueIntegrationBackfillAdmission(boss, { kind, data: job.data }); + } + return jobDone({ jobs: jobs.length }); + }; +} diff --git a/src/lib/jobs/integration-poll-queues.ts b/src/lib/jobs/integration-poll-queues.ts index addd5e94d..415921c0e 100644 --- a/src/lib/jobs/integration-poll-queues.ts +++ b/src/lib/jobs/integration-poll-queues.ts @@ -1,27 +1,85 @@ /** - * Queue names for the poll-only integration syncs. + * Names and crons of the integration sync queues. * - * Split out of `reminder/register-integration-sync.ts` so a request-path caller - * (an OAuth callback enqueueing the first sync at connect time) can name the - * queue without importing the registrar — which would drag every worker handler - * and its transitive graph into the request bundle. Same shape as - * `STRAVA_BACKFILL_QUEUE`, which the Strava callback already imports from its - * own module. + * A light module on purpose: the worker registry declares these queues from + * here (`registry/integration-sync.ts`), and a request-path caller (an OAuth + * callback enqueueing the first sync, a webhook enqueueing one resource) names + * a queue without loading the sync it feeds. * - * The registrar remains the single place that creates, schedules, and binds a - * handler to each of these; the values here are its source. A name declared - * here but missing from `allQueues` there is the v1.4.37 dead-queue class, and - * the queue-wiring guards read both files for exactly that reason. + * The hourly minutes are staggered so the polls do not land on one boss poll: + * used are :00 :05 :08 :11 :13 :15 :17 :18 :20 :35 :41 :47 :50. */ -/** Hourly Nightscout CGM poll (:11). */ +/** Withings measure catch-net (the webhook is primary). */ +export const WITHINGS_SYNC_QUEUE = "withings-fallback-sync"; +export const WITHINGS_SYNC_CRON = "0 * * * *"; + +/** + * Withings activity and sleep: webhook-primary (appli 16 / 44), with an hourly + * catch-net for the deliveries Withings drops. + */ +export const WITHINGS_ACTIVITY_QUEUE = "withings-activity-sync"; +export const WITHINGS_ACTIVITY_CRON = "0 * * * *"; +export const WITHINGS_SLEEP_QUEUE = "withings-sleep-sync"; +export const WITHINGS_SLEEP_CRON = "15 * * * *"; + +/** + * Withings ECG catch-net. The queue is webhook-primary (appli 54), but appli 54 + * is not in the subscription set and the manual sync measures only, so a + * watch-only account would never have its strips pulled without this walk. + */ +export const WITHINGS_ECG_SYNC_CRON = "41 * * * *"; + +/** Daily sweep of the Withings OAuth state ledger. */ +export const WITHINGS_OAUTH_STATE_CLEANUP_QUEUE = + "withings-oauth-state-cleanup"; +export const WITHINGS_OAUTH_STATE_CLEANUP_CRON = "20 3 * * *"; + +/** + * WHOOP: recovery, sleep and workout are webhook-primary with an hourly + * catch-net; cycle has no webhook, so its cron is the only driver. + */ +export const WHOOP_RECOVERY_SYNC_QUEUE = "whoop-recovery-sync"; +export const WHOOP_RECOVERY_SYNC_CRON = "5 * * * *"; +export const WHOOP_SLEEP_SYNC_QUEUE = "whoop-sleep-sync"; +export const WHOOP_SLEEP_SYNC_CRON = "20 * * * *"; +export const WHOOP_WORKOUT_SYNC_QUEUE = "whoop-workout-sync"; +export const WHOOP_WORKOUT_SYNC_CRON = "35 * * * *"; +export const WHOOP_CYCLE_SYNC_QUEUE = "whoop-cycle-sync"; +export const WHOOP_CYCLE_SYNC_CRON = "50 * * * *"; + +/** Daily sweep of the WHOOP OAuth state ledger. */ +export const WHOOP_OAUTH_STATE_CLEANUP_QUEUE = "whoop-oauth-state-cleanup"; +export const WHOOP_OAUTH_STATE_CLEANUP_CRON = "22 3 * * *"; + +/** Google Health poll (no webhook). */ +export const GOOGLE_HEALTH_SYNC_QUEUE = "google-health-sync"; +export const GOOGLE_HEALTH_SYNC_CRON = "18 * * * *"; + +/** Daily sweep of the Google Health OAuth state ledger. */ +export const GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_QUEUE = + "google-health-oauth-state-cleanup"; +export const GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_CRON = "26 3 * * *"; + +/** Nightscout CGM poll. */ export const NIGHTSCOUT_SYNC_QUEUE = "nightscout-sync"; +export const NIGHTSCOUT_SYNC_CRON = "11 * * * *"; -/** Hourly Polar AccessLink poll (:13). */ +/** Polar AccessLink poll. */ export const POLAR_SYNC_QUEUE = "polar-sync"; +export const POLAR_SYNC_CRON = "13 * * * *"; -/** Hourly Oura Cloud poll (:15). */ +/** Oura Cloud poll. */ export const OURA_SYNC_QUEUE = "oura-sync"; +export const OURA_SYNC_CRON = "15 * * * *"; -/** Hourly Strava poll (:17). */ +/** Strava poll (the webhook is not wired). */ export const STRAVA_SYNC_QUEUE = "strava-sync"; +export const STRAVA_SYNC_CRON = "17 * * * *"; + +/** + * Daily sweep of the native OIDC handoff ledger. Handoff codes are single-use + * and expire at read, so this only reaps rows the app never came back for. + */ +export const OIDC_NATIVE_HANDOFF_CLEANUP_QUEUE = "oidc-native-handoff-cleanup"; +export const OIDC_NATIVE_HANDOFF_CLEANUP_CRON = "28 3 * * *"; diff --git a/src/lib/jobs/integration-watchdog-queue.ts b/src/lib/jobs/integration-watchdog-queue.ts new file mode 100644 index 000000000..6324b1e1f --- /dev/null +++ b/src/lib/jobs/integration-watchdog-queue.ts @@ -0,0 +1,18 @@ +/** + * The queue facts of `integration-watchdog.ts`: its name and, where it has them, its + * schedule and concurrency. They live apart from the handler so the worker + * registry and every sender can read them without loading the job. + */ +export const INTEGRATION_WATCHDOG_HOURLY_QUEUE = "integration-watchdog-hourly"; + +export const INTEGRATION_WATCHDOG_DAILY_QUEUE = "integration-watchdog-daily"; + +/** + * :47 is a free minute off every hourly sync tick (used: 0, 5, 8, 11, 13, 15, + * 17, 18, 20, 30, 35, 41, 50), late enough in the hour that the syncs before it + * have written what they fetched. + */ +export const INTEGRATION_WATCHDOG_HOURLY_CRON = "47 * * * *"; + +/** Once a day, inside the maintenance window, off the hourly :47. */ +export const INTEGRATION_WATCHDOG_DAILY_CRON = "52 4 * * *"; diff --git a/src/lib/jobs/integration-watchdog.ts b/src/lib/jobs/integration-watchdog.ts index f4ce883e8..155ee95a5 100644 --- a/src/lib/jobs/integration-watchdog.ts +++ b/src/lib/jobs/integration-watchdog.ts @@ -16,18 +16,6 @@ import { type WatchdogMode, } from "@/lib/integrations/watchdog"; -export const INTEGRATION_WATCHDOG_HOURLY_QUEUE = "integration-watchdog-hourly"; -export const INTEGRATION_WATCHDOG_DAILY_QUEUE = "integration-watchdog-daily"; - -/** - * :47 is a free minute off every hourly sync tick (used: 0, 5, 8, 11, 13, 15, - * 17, 18, 20, 30, 35, 41, 50), late enough in the hour that the syncs before it - * have written what they fetched. - */ -export const INTEGRATION_WATCHDOG_HOURLY_CRON = "47 * * * *"; -/** Once a day, inside the maintenance window, off the hourly :47. */ -export const INTEGRATION_WATCHDOG_DAILY_CRON = "52 4 * * *"; - const LOCK_KEY = "job:integration-watchdog:pass"; export interface IntegrationWatchdogPayload { diff --git a/src/lib/jobs/measurement-fold-repair-queue.ts b/src/lib/jobs/measurement-fold-repair-queue.ts new file mode 100644 index 000000000..ff3060202 --- /dev/null +++ b/src/lib/jobs/measurement-fold-repair-queue.ts @@ -0,0 +1,6 @@ +/** + * The queue facts of `measurement-fold-repair.ts`: its name and, where it has them, its + * schedule, concurrency and send options. They live apart from the job so the + * worker registry and every sender can read them without loading it. + */ +export const MEASUREMENT_FOLD_REPAIR_QUEUE = "measurement-fold-repair"; diff --git a/src/lib/jobs/measurement-fold-repair.ts b/src/lib/jobs/measurement-fold-repair.ts index 1efc75a9c..ffab94285 100644 --- a/src/lib/jobs/measurement-fold-repair.ts +++ b/src/lib/jobs/measurement-fold-repair.ts @@ -90,10 +90,8 @@ import { holdAccountFoldLock, } from "@/lib/export/restore-lock"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; -import { - COMPACTION_TOMBSTONE_PURGE_QUEUE, - COMPACTION_TOMBSTONE_PURGE_SINGLETON_KEY, -} from "@/lib/jobs/compaction-tombstone-purge"; +import { COMPACTION_TOMBSTONE_PURGE_SINGLETON_KEY } from "@/lib/jobs/compaction-tombstone-purge"; +import { COMPACTION_TOMBSTONE_PURGE_QUEUE } from "@/lib/jobs/compaction-tombstone-purge-queue"; import { jobBudget } from "@/lib/jobs/job-budget"; import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome"; import { reportJobRun, type JobRunCandidate } from "@/lib/jobs/job-run-report"; @@ -136,8 +134,7 @@ import { } from "@/lib/measurements/fold-constituents"; import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups"; import { localDayWindow } from "@/lib/tz/local-day"; - -export const MEASUREMENT_FOLD_REPAIR_QUEUE = "measurement-fold-repair"; +import { MEASUREMENT_FOLD_REPAIR_QUEUE } from "./measurement-fold-repair-queue"; /** Delay before the follow-up of a run that stopped early, in seconds. */ export const FOLD_REPAIR_CONTINUATION_DELAY_SECONDS = 30; diff --git a/src/lib/jobs/medication-intake-import-queue.ts b/src/lib/jobs/medication-intake-import-queue.ts new file mode 100644 index 000000000..da52962dc --- /dev/null +++ b/src/lib/jobs/medication-intake-import-queue.ts @@ -0,0 +1,8 @@ +/** + * The queue facts of `medication-intake-import.ts`: its name and, where it has them, its + * schedule and concurrency. They live apart from the handler so the worker + * registry and every sender can read them without loading the job. + */ +export const MEDICATION_INTAKE_IMPORT_QUEUE = "medication-intake-import"; + +export const MEDICATION_INTAKE_IMPORT_CONCURRENCY = 2; diff --git a/src/lib/jobs/medication-intake-import.ts b/src/lib/jobs/medication-intake-import.ts index 3fda3d1b2..bea85b8c1 100644 --- a/src/lib/jobs/medication-intake-import.ts +++ b/src/lib/jobs/medication-intake-import.ts @@ -9,9 +9,6 @@ import { jobBudget } from "@/lib/jobs/job-budget"; import { enqueueSupplementNutrientRecompute } from "@/lib/medications/ingredients/recompute-job"; import { consumeImportedIntakesBatch } from "@/lib/medications/inventory/consumption"; import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; - -export const MEDICATION_INTAKE_IMPORT_QUEUE = "medication-intake-import"; -export const MEDICATION_INTAKE_IMPORT_CONCURRENCY = 2; export const MEDICATION_INTAKE_IMPORT_CHUNK_SIZE = 100; export const MEDICATION_INTAKE_IMPORT_STALE_AFTER_MS = 15 * 60_000; diff --git a/src/lib/jobs/medication-low-stock-queue.ts b/src/lib/jobs/medication-low-stock-queue.ts new file mode 100644 index 000000000..aeeca6dc3 --- /dev/null +++ b/src/lib/jobs/medication-low-stock-queue.ts @@ -0,0 +1,14 @@ +/** + * The queue facts of `medication-low-stock.ts`: its name and, where it has them, its + * schedule and concurrency. They live apart from the handler so the worker + * registry and every sender can read them without loading the job. + */ +/** Pg-boss queue + cron — imported by the reminder worker's bootstrap. */ +export const MEDICATION_LOW_STOCK_QUEUE = "medication-low-stock"; + +/** + * 09:00 Europe/Berlin — a supply alert is an errand prompt ("order a + * refill"), not a night signal; it fires once daily at a time the user + * can act on it. + */ +export const MEDICATION_LOW_STOCK_CRON = "0 9 * * *"; diff --git a/src/lib/jobs/medication-low-stock.ts b/src/lib/jobs/medication-low-stock.ts index 5c2e8af5c..42b2db001 100644 --- a/src/lib/jobs/medication-low-stock.ts +++ b/src/lib/jobs/medication-low-stock.ts @@ -86,15 +86,6 @@ import { resolveJobLocale } from "@/lib/i18n/job-locale"; import { annotate, getEvent } from "@/lib/logging/context"; import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; -/** Pg-boss queue + cron — imported by the reminder worker's bootstrap. */ -export const MEDICATION_LOW_STOCK_QUEUE = "medication-low-stock"; -/** - * 09:00 Europe/Berlin — a supply alert is an errand prompt ("order a - * refill"), not a night signal; it fires once daily at a time the user - * can act on it. - */ -export const MEDICATION_LOW_STOCK_CRON = "0 9 * * *"; - /** * An as-needed medication warns when this many doses or fewer are left: no * cadence says how many days that is, so the floor is a dose count. diff --git a/src/lib/jobs/mood-prognosis-refresh-queue.ts b/src/lib/jobs/mood-prognosis-refresh-queue.ts new file mode 100644 index 000000000..41f75c78c --- /dev/null +++ b/src/lib/jobs/mood-prognosis-refresh-queue.ts @@ -0,0 +1,8 @@ +/** + * The queue facts of `mood-prognosis-refresh.ts`: its name and, where it has them, its + * schedule and concurrency. They live apart from the handler so the worker + * registry and every sender can read them without loading the job. + */ +export const MOOD_PROGNOSIS_REFRESH_QUEUE = "mood-prognosis-refresh"; + +export const MOOD_PROGNOSIS_REFRESH_CRON = "35 4 * * *"; diff --git a/src/lib/jobs/mood-prognosis-refresh.ts b/src/lib/jobs/mood-prognosis-refresh.ts index 30ab95fba..e63ff411a 100644 --- a/src/lib/jobs/mood-prognosis-refresh.ts +++ b/src/lib/jobs/mood-prognosis-refresh.ts @@ -50,10 +50,6 @@ import { isModuleEnabled } from "@/lib/modules/gate"; import { getWorkerPrisma } from "./reminder/shared"; -export const MOOD_PROGNOSIS_REFRESH_QUEUE = "mood-prognosis-refresh"; - -export const MOOD_PROGNOSIS_REFRESH_CRON = "35 4 * * *"; - export interface MoodPrognosisRefreshPayload { triggeredAt: string; } diff --git a/src/lib/jobs/offhost-backup-queue.ts b/src/lib/jobs/offhost-backup-queue.ts new file mode 100644 index 000000000..13e6a0353 --- /dev/null +++ b/src/lib/jobs/offhost-backup-queue.ts @@ -0,0 +1,27 @@ +/** + * The queue facts of `offhost-backup.ts`: its name and, where it has them, its + * schedule, concurrency and send options. They live apart from the job so the + * worker registry and every sender can read them without loading it. + */ +import { BACKUP_HEARTBEAT_SECONDS } from "@/lib/jobs/data-backup-policy"; + +/** + * The off-host pass's expiry: four hours. It uploads every account and + * already stopped between accounts at three quarters of the default fifteen + * minutes, which on an instance of any size meant a retry per night; one + * large account past the default was retried beside itself. Four hours with + * the same budget and a lock (`lockedPass`) leaves room for the night's pass. + */ +export const OFFHOST_BACKUP_EXPIRE_SECONDS = 4 * 60 * 60; + +export const OFFHOST_BACKUP_QUEUE = "data-backup-offhost"; + +/** + * What the nightly schedule sends with: the four hours above, and the + * heartbeat that notices a process that died under the pass within minutes + * rather than at the end of them (`BACKUP_HEARTBEAT_SECONDS`). + */ +export const OFFHOST_BACKUP_SEND_OPTIONS = { + expireInSeconds: OFFHOST_BACKUP_EXPIRE_SECONDS, + heartbeatSeconds: BACKUP_HEARTBEAT_SECONDS, +} as const; diff --git a/src/lib/jobs/offhost-backup.ts b/src/lib/jobs/offhost-backup.ts index 1016ce032..cf6fbf1ba 100644 --- a/src/lib/jobs/offhost-backup.ts +++ b/src/lib/jobs/offhost-backup.ts @@ -41,35 +41,14 @@ import { orderInterruptedLast, readAccountsToTakeLast, } from "@/lib/jobs/backup-pass-attempts"; -import { BACKUP_HEARTBEAT_SECONDS } from "@/lib/jobs/data-backup-policy"; import { annotate, getEvent } from "@/lib/logging/context"; import { envValue } from "@/lib/env"; +import { OFFHOST_BACKUP_QUEUE } from "./offhost-backup-queue"; const ALGORITHM = "aes-256-gcm"; const IV_LENGTH = 12; const TAG_LENGTH = 16; -/** - * The off-host pass's expiry: four hours. It uploads every account and - * already stopped between accounts at three quarters of the default fifteen - * minutes, which on an instance of any size meant a retry per night; one - * large account past the default was retried beside itself. Four hours with - * the same budget and a lock (`lockedPass`) leaves room for the night's pass. - */ -export const OFFHOST_BACKUP_EXPIRE_SECONDS = 4 * 60 * 60; - -export const OFFHOST_BACKUP_QUEUE = "data-backup-offhost"; - -/** - * What the nightly schedule sends with: the four hours above, and the - * heartbeat that notices a process that died under the pass within minutes - * rather than at the end of them (`BACKUP_HEARTBEAT_SECONDS`). - */ -export const OFFHOST_BACKUP_SEND_OPTIONS = { - expireInSeconds: OFFHOST_BACKUP_EXPIRE_SECONDS, - heartbeatSeconds: BACKUP_HEARTBEAT_SECONDS, -} as const; - export interface OffhostBackupConfig { endpoint: string; bucket: string; diff --git a/src/lib/jobs/offhost-purge-queue.ts b/src/lib/jobs/offhost-purge-queue.ts new file mode 100644 index 000000000..4630131a5 --- /dev/null +++ b/src/lib/jobs/offhost-purge-queue.ts @@ -0,0 +1,9 @@ +/** + * The queue facts of `offhost-purge.ts`: its name and, where it has them, its + * schedule, concurrency and send options. They live apart from the job so the + * worker registry and every sender can read them without loading it. + */ +export const OFFHOST_PURGE_QUEUE = "offhost-backup-purge"; + +/** 03:40, after the nightly upload (02:30) has had its hour. */ +export const OFFHOST_PURGE_CRON = "40 3 * * *"; diff --git a/src/lib/jobs/offhost-purge.ts b/src/lib/jobs/offhost-purge.ts index cc08a3acc..2cdf06cd8 100644 --- a/src/lib/jobs/offhost-purge.ts +++ b/src/lib/jobs/offhost-purge.ts @@ -46,10 +46,7 @@ import { reportWorkerError } from "@/lib/jobs/report-worker-error"; import { getWorkerPrisma } from "@/lib/jobs/reminder/shared"; import { withBackgroundEvent } from "@/lib/logging/background"; import { logCaught } from "@/lib/logging/signal"; - -export const OFFHOST_PURGE_QUEUE = "offhost-backup-purge"; -/** 03:40, after the nightly upload (02:30) has had its hour. */ -export const OFFHOST_PURGE_CRON = "40 3 * * *"; +import { OFFHOST_PURGE_QUEUE } from "./offhost-purge-queue"; export type OffhostPurgeReason = "account_deleted" | "data_wiped" | "managed_profile_deleted"; diff --git a/src/lib/jobs/oura-backfill-queue.ts b/src/lib/jobs/oura-backfill-queue.ts new file mode 100644 index 000000000..47fdb4c66 --- /dev/null +++ b/src/lib/jobs/oura-backfill-queue.ts @@ -0,0 +1,9 @@ +/** + * The queue facts of `oura-backfill.ts`: its name and, where it has them, its + * schedule and concurrency. They live apart from the handler so the worker + * registry and every sender can read them without loading the job. + */ +export const OURA_BACKFILL_QUEUE = "oura-backfill"; + +/** One account at a time; the shared lane bounds the instance. */ +export const OURA_BACKFILL_CONCURRENCY = 1; diff --git a/src/lib/jobs/oura-backfill.ts b/src/lib/jobs/oura-backfill.ts index 6fad28bef..413267412 100644 --- a/src/lib/jobs/oura-backfill.ts +++ b/src/lib/jobs/oura-backfill.ts @@ -68,11 +68,7 @@ import { withoutDenied, type OuraBackfillProgress, } from "@/lib/oura/backfill-progress"; - -export const OURA_BACKFILL_QUEUE = "oura-backfill"; - -/** One account at a time; the shared lane bounds the instance. */ -export const OURA_BACKFILL_CONCURRENCY = 1; +import { OURA_BACKFILL_QUEUE } from "./oura-backfill-queue"; /** * One slice: five minutes of walking, then the account is let go and the diff --git a/src/lib/jobs/period-narrative-queue.ts b/src/lib/jobs/period-narrative-queue.ts new file mode 100644 index 000000000..5628280d9 --- /dev/null +++ b/src/lib/jobs/period-narrative-queue.ts @@ -0,0 +1,24 @@ +/** + * The queue facts of `period-narrative-shared.ts`: its name and, where it has them, its + * schedule, concurrency and send options. They live apart from the job so the + * worker registry and every sender can read them without loading it. + */ +export const PERIOD_NARRATIVE_QUEUE = "period-narrative-warm"; + +/** + * The job's expiry: three hours. On a boundary night the scheduled pass + * writes up to two narratives for each of 200 accounts, which can outlast + * pg-boss's fifteen-minute default. The pass stops itself at three quarters + * of this (`jobBudget`) and holds a lock (`lockedPass`); every send and the + * schedule carry it. + */ +export const PERIOD_NARRATIVE_EXPIRE_SECONDS = 3 * 60 * 60; + +/** + * Nightly at 05:05 Europe/Berlin — inside the existing maintenance window, + * after the comprehensive insight pre-generation (04:30) and the computed + * scores (04:45–04:55) so the rollup signals it reads are already folded. + * The handler only does real work near a week/month boundary; on every other + * night it short-circuits cheaply. + */ +export const PERIOD_NARRATIVE_CRON = "5 5 * * *"; diff --git a/src/lib/jobs/period-narrative-shared.ts b/src/lib/jobs/period-narrative-shared.ts index 788ab0258..ac43bb66d 100644 --- a/src/lib/jobs/period-narrative-shared.ts +++ b/src/lib/jobs/period-narrative-shared.ts @@ -21,26 +21,10 @@ import { import type { NarrativePeriod } from "@/lib/insights/narrative/period-narrative"; import type { Locale } from "@/lib/i18n/config"; import { logCaught } from "@/lib/logging/signal"; - -export const PERIOD_NARRATIVE_QUEUE = "period-narrative-warm"; - -/** - * The job's expiry: three hours. On a boundary night the scheduled pass - * writes up to two narratives for each of 200 accounts, which can outlast - * pg-boss's fifteen-minute default. The pass stops itself at three quarters - * of this (`jobBudget`) and holds a lock (`lockedPass`); every send and the - * schedule carry it. - */ -export const PERIOD_NARRATIVE_EXPIRE_SECONDS = 3 * 60 * 60; - -/** - * Nightly at 05:05 Europe/Berlin — inside the existing maintenance window, - * after the comprehensive insight pre-generation (04:30) and the computed - * scores (04:45–04:55) so the rollup signals it reads are already folded. - * The handler only does real work near a week/month boundary; on every other - * night it short-circuits cheaply. - */ -export const PERIOD_NARRATIVE_CRON = "5 5 * * *"; +import { + PERIOD_NARRATIVE_QUEUE, + PERIOD_NARRATIVE_EXPIRE_SECONDS, +} from "./period-narrative-queue"; export interface PeriodNarrativePayload { /** Set on the nightly scheduled tick (informational). */ diff --git a/src/lib/jobs/period-narrative-warm.ts b/src/lib/jobs/period-narrative-warm.ts index d162d8506..51aa05dd9 100644 --- a/src/lib/jobs/period-narrative-warm.ts +++ b/src/lib/jobs/period-narrative-warm.ts @@ -37,11 +37,13 @@ import type { NarrativePeriod } from "@/lib/insights/narrative/period-narrative" import { defaultLocale } from "@/lib/i18n/config"; import { resolveJobLocale } from "@/lib/i18n/job-locale"; import { - PERIOD_NARRATIVE_QUEUE, - PERIOD_NARRATIVE_CRON, enqueueNarrativeWarm, type PeriodNarrativePayload, } from "@/lib/jobs/period-narrative-shared"; +import { + PERIOD_NARRATIVE_QUEUE, + PERIOD_NARRATIVE_CRON, +} from "@/lib/jobs/period-narrative-queue"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; export { PERIOD_NARRATIVE_QUEUE, PERIOD_NARRATIVE_CRON, enqueueNarrativeWarm }; diff --git a/src/lib/jobs/queue-runtime.ts b/src/lib/jobs/queue-runtime.ts index 80b1feeb1..071481bfe 100644 --- a/src/lib/jobs/queue-runtime.ts +++ b/src/lib/jobs/queue-runtime.ts @@ -6,7 +6,7 @@ * it, and it does not stop the handler, so a job that legitimately needs longer * ends up running twice beside itself. No queue policy prevents that: the * policies constrain job rows, and an expired row is no longer active. Every - * queue bound with `createAndWork` is therefore declared here as one of: + * queue the worker registry declares is therefore declared here as one of: * * - `short`: a job cannot come near fifteen minutes (one account's bounded * window, one document, a forwarder, a bounded delete). It relies on the @@ -28,15 +28,15 @@ import { INTEGRATION_BACKFILL_EXPIRE_SECONDS } from "@/lib/jobs/integration-backfill-admission"; import { NIGHTLY_INSIGHT_PASS_EXPIRE_SECONDS } from "@/lib/jobs/reminder/registrar-shared"; import { INSIGHT_PREGENERATE_EXPIRE_SECONDS } from "@/lib/jobs/insight-pregenerate-shared"; -import { PERIOD_NARRATIVE_EXPIRE_SECONDS } from "@/lib/jobs/period-narrative-shared"; +import { PERIOD_NARRATIVE_EXPIRE_SECONDS } from "@/lib/jobs/period-narrative-queue"; import { PR_DETECTION_EXPIRE_SECONDS } from "@/lib/jobs/pr-detection"; import { ENCRYPTION_KEY_ROTATE_EXPIRE_SECONDS } from "@/lib/jobs/encryption-key-rotate"; import { CONTENT_INDEX_BACKFILL_EXPIRE_SECONDS } from "@/lib/jobs/document-content-index-backfill"; import { DOCUMENT_AI_RUN_EXPIRE_SECONDS } from "@/lib/jobs/document-ai-run"; import { DATA_BACKUP_SEND_OPTIONS } from "@/lib/jobs/data-backup-policy"; -import { OFFHOST_BACKUP_EXPIRE_SECONDS } from "@/lib/jobs/offhost-backup"; +import { OFFHOST_BACKUP_EXPIRE_SECONDS } from "@/lib/jobs/offhost-backup-queue"; import { BACKUP_RESTORE_EXPIRE_SECONDS } from "@/lib/jobs/backup-restore"; -import { RESTORE_DRILL_EXPIRE_SECONDS } from "@/lib/jobs/restore-drill"; +import { RESTORE_DRILL_EXPIRE_SECONDS } from "@/lib/jobs/restore-drill-queue"; import { APPLE_HEALTH_IMPORT_SEND_OPTIONS } from "@/lib/jobs/apple-health-import-worker"; import { MEDICATION_INTAKE_IMPORT_SEND_OPTIONS } from "@/lib/jobs/medication-intake-import"; import { HEALTH_CONNECT_IMPORT_SEND_OPTIONS } from "@/lib/jobs/health-connect-import-worker"; @@ -47,8 +47,8 @@ import { ROLLUP_FULL_BACKFILL_EXPIRE_SECONDS } from "@/lib/rollups/measurement-r /** pg-boss's default job expiry, in seconds. */ export const DEFAULT_JOB_EXPIRE_SECONDS = 15 * 60; -/** Where a check lives: `binding` is the `createAndWork` call itself. */ -export type SourceSite = "binding" | { file: string; fn: string }; +/** Where a check lives: a function (or const) in a file under `src/`. */ +export type SourceSite = { file: string; fn: string }; export type QueueRuntime = | { runtime: "short"; why: string } @@ -64,8 +64,9 @@ export type QueueRuntime = /** Where the handler stops on the job's budget, or `oneShot`. */ stop: SourceSite | { oneShot: string }; /** - * What keeps a second run from working beside this one: `lockedPass` - * at the binding, or a marker in the named function. + * What keeps a second run from working beside this one: the `lock` + * its registry definition declares (`lockedPass`), or a marker in the + * named function. */ exclusive: "lockedPass" | { marker: string; at: SourceSite }; why: string; @@ -177,7 +178,10 @@ export const QUEUE_RUNTIME: Readonly> = { runtime: "long", expireInSeconds: PERIOD_NARRATIVE_EXPIRE_SECONDS, expiryVia: ["PERIOD_NARRATIVE_EXPIRE_SECONDS"], - stop: "binding", + stop: { + file: "lib/jobs/reminder/status-handlers.ts", + fn: "handlePeriodNarrative", + }, exclusive: "lockedPass", why: "On a boundary night up to 200 accounts with a provider call per period each; a single-account warm is short.", }, @@ -198,7 +202,10 @@ export const QUEUE_RUNTIME: Readonly> = { runtime: "long", expireInSeconds: ROLLUP_FULL_BACKFILL_EXPIRE_SECONDS, expiryVia: ["ROLLUP_FULL_BACKFILL_EXPIRE_SECONDS"], - stop: "binding", + stop: { + file: "lib/jobs/reminder/rollup-handlers.ts", + fn: "handleRollupFullBackfill", + }, exclusive: "lockedPass", why: "One account's five-year fold: four aggregates under the statement timeout and four persist transactions of up to 120 s each, about thirteen minutes at worst under the defaults and unbounded when the operator lifts the statement timeout.", }, @@ -220,22 +227,34 @@ export const QUEUE_RUNTIME: Readonly> = { ), "mean-consolidation": { runtime: "budgeted", - budget: "binding", + budget: { + file: "lib/jobs/reminder/rollup-handlers.ts", + fn: "handleMeanConsolidation", + }, why: "One account's whole history, a day at a time; stops at three quarters of the default expiry and resumes.", }, "dense-intraday-retention": { runtime: "budgeted", - budget: "binding", + budget: { + file: "lib/jobs/reminder/rollup-handlers.ts", + fn: "handleDenseIntradayRetention", + }, why: "A dense account's history, a day at a time; stops at three quarters of the default expiry and resumes.", }, "dense-intraday-hourly-rebuild": { runtime: "budgeted", - budget: "binding", + budget: { + file: "lib/jobs/reminder/rollup-handlers.ts", + fn: "handleDenseIntradayHourlyRebuild", + }, why: "A dense account's hourly rows, a day at a time; stops at three quarters of the default expiry and resumes.", }, "drain-per-sample-cumulative": { runtime: "budgeted", - budget: "binding", + budget: { + file: "lib/jobs/reminder/rollup-handlers.ts", + fn: "drainCumulativeHandler", + }, why: "Every account's per-sample drains; stops at three quarters of the default expiry and hands the rest to a continuation job.", }, @@ -471,7 +490,10 @@ export const QUEUE_RUNTIME: Readonly> = { runtime: "long", expireInSeconds: CONTENT_INDEX_BACKFILL_EXPIRE_SECONDS, expiryVia: ["CONTENT_INDEX_BACKFILL_EXPIRE_SECONDS"], - stop: "binding", + stop: { + file: "lib/jobs/reminder/document-handlers.ts", + fn: "handleContentIndexBackfill", + }, exclusive: "lockedPass", why: "Up to 200 documents of one account, a provider transcription each.", }, diff --git a/src/lib/jobs/recovery-score-queue.ts b/src/lib/jobs/recovery-score-queue.ts new file mode 100644 index 000000000..181e66c5f --- /dev/null +++ b/src/lib/jobs/recovery-score-queue.ts @@ -0,0 +1,14 @@ +/** + * The queue facts of `recovery-score.ts`: its name and, where it has them, its + * schedule and concurrency. They live apart from the handler so the worker + * registry and every sender can read them without loading the job. + */ +export const RECOVERY_SCORE_QUEUE = "recovery-score-compute"; + +/** + * Daily at 04:45 Europe/Berlin — inside the existing 03:xx–04:xx maintenance + * window, after the cumulative drain (03:45) + the daily-mean consolidation + * and the insight pre-generation (04:30) so the nightly signals it reads are + * already folded into the rollup tier by the time it runs. + */ +export const RECOVERY_SCORE_CRON = "45 4 * * *"; diff --git a/src/lib/jobs/recovery-score.ts b/src/lib/jobs/recovery-score.ts index cc2a2fb2f..c0a4a796f 100644 --- a/src/lib/jobs/recovery-score.ts +++ b/src/lib/jobs/recovery-score.ts @@ -24,16 +24,6 @@ import type { PrismaClient } from "@/generated/prisma/client"; import { persistRecoveryScore } from "@/lib/insights/recovery-score"; import { runScoreBatch } from "@/lib/insights/score-row"; -export const RECOVERY_SCORE_QUEUE = "recovery-score-compute"; - -/** - * Daily at 04:45 Europe/Berlin — inside the existing 03:xx–04:xx maintenance - * window, after the cumulative drain (03:45) + the daily-mean consolidation - * and the insight pre-generation (04:30) so the nightly signals it reads are - * already folded into the rollup tier by the time it runs. - */ -export const RECOVERY_SCORE_CRON = "45 4 * * *"; - /** * Per-run user cap. A nightly fan-out across every user could starve the * pool; the discovery query is ordered so users with the most recent vital diff --git a/src/lib/jobs/registry/__tests__/registry-contract.test.ts b/src/lib/jobs/registry/__tests__/registry-contract.test.ts new file mode 100644 index 000000000..afcbdc995 --- /dev/null +++ b/src/lib/jobs/registry/__tests__/registry-contract.test.ts @@ -0,0 +1,129 @@ +/** + * The contract of the worker registry, checked over every declaration. + * + * The dead-queue class (a queue named but never created, scheduled or + * drained) cannot be written any more: a definition is the queue, its cron, + * its policy and its handler in one object, and `registerJobGroup` turns each + * into all four. What is left to check is what the type system cannot see: + * + * - every queue is declared once, under a well-formed name, and no retired + * queue is declared again; + * - every cron has five fields, every policy names one this codebase uses + * and says why; + * - the groups register in the order the worker always used; + * - loading the registry loads none of the work: its static import graph + * stays inside a small budget and reaches no handler module. A handler is + * named only through its definition's dynamic `import("…")`. + * + * What pg-boss is left holding after registration is pinned byte for byte by + * `tests/integration/queue-registration-parity.test.ts`, which also loads + * every handler once. + */ +import { describe, expect, it } from "vitest"; + +import { staticImportClosure } from "@/__tests__/helpers/source-metrics"; +import { JOB_GROUP_NAMES } from "../define-job"; +import { allJobDefinitions, JOB_GROUPS } from "../index"; + +const definitions = allJobDefinitions(); + +/** Modules the registry may not load: the work the definitions point at. */ +const HANDLER_GRAPH = + /^lib\/(?:jobs\/reminder\/(?!registrar-shared\.ts|shared\.ts)|ai\/|insights\/|withings\/|whoop\/|oura\/|polar\/|strava\/|google-health\/|nightscout\/|dawarich\/|documents\/|export\/|import\/|measurements\/import)/; + +/** + * Light modules inside those folders the registry may load, with the reason. + */ +const ALLOWED: Readonly> = { + "lib/ai/coach/coach-memory-shared.ts": + "the Coach memory queue's name and payload, with its byte codec; no model code", + "lib/ai/coach/bytes-codec.ts": "pulled by coach-memory-shared; a codec", + "lib/export/restore-lock.ts": + "the per-account restore lock the consolidation passes take; a lock primitive", + "lib/export/restore-failure-cause.ts": + "pulled by restore-lock; an error type", +}; + +/** + * The registry's static graph: the declarations, the light queue-fact + * modules and the boot infrastructure they share (the database client, the + * logger, the lane and the lock). Measured at 288 on 2026-10-23, against the + * 1,164 modules the five hand-written registrars loaded; it may only fall. + */ +const REGISTRY_GRAPH_BUDGET = 300; + +describe("worker registry", () => { + it("declares every queue once, under a well-formed name", () => { + expect(definitions.length).toBeGreaterThan(100); + const names = definitions.map((d) => d.queue); + const twice = names.filter((n, i) => names.indexOf(n) !== i); + expect(twice).toEqual([]); + expect(names.filter((n) => !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(n))).toEqual( + [], + ); + }); + + it("declares no retired queue again", () => { + const declared = new Set(definitions.map((d) => d.queue)); + const retired = JOB_GROUPS.flatMap((g) => g.retired ?? []); + expect(retired.length).toBeGreaterThan(0); + expect(retired.filter((q) => declared.has(q))).toEqual([]); + }); + + it("gives every cron five fields and every policy a known kind and a reason", () => { + const crons = definitions.filter((d) => d.cron); + expect(crons.length).toBeGreaterThan(60); + expect( + crons + .filter((d) => d.cron!.trim().split(/\s+/).length !== 5) + .map((d) => d.queue), + ).toEqual([]); + const policies = definitions.filter((d) => d.policy); + expect(policies.length).toBeGreaterThan(30); + expect( + policies + .filter( + (d) => + !["short", "exclusive"].includes(d.policy!.policy) || + d.policy!.reason.length < 30, + ) + .map((d) => d.queue), + ).toEqual([]); + }); + + it("binds every queue with an explicit concurrency", () => { + expect( + definitions + .filter( + (d) => + !Number.isInteger(d.work.localConcurrency) || + (d.work.localConcurrency ?? 0) < 1, + ) + .map((d) => d.queue), + ).toEqual([]); + }); + + it("registers the groups in the worker's order", () => { + expect(JOB_GROUPS.map((g) => g.name)).toEqual([...JOB_GROUP_NAMES]); + }); + + it("loads none of the work when the registry loads", () => { + const graph = staticImportClosure(["lib/jobs/registry/index.ts"]); + // A floor, so a walk that resolves nothing cannot pass. + expect(graph.size).toBeGreaterThan(20); + expect(graph.has("lib/jobs/registry/maintenance.ts")).toBe(true); + expect( + [...graph].filter((m) => HANDLER_GRAPH.test(m) && !(m in ALLOWED)).sort(), + ).toEqual([]); + expect(graph.size).toBeLessThanOrEqual(REGISTRY_GRAPH_BUDGET); + }); + + it("catches a handler module in the registry graph (planted)", () => { + // The same check over the graph a registrar that imported its handlers + // would have. + const graph = staticImportClosure(["lib/jobs/reminder/status-handlers.ts"]); + expect( + [...graph].filter((m) => HANDLER_GRAPH.test(m)).length, + ).toBeGreaterThan(0); + }); +}); diff --git a/src/lib/jobs/registry/__tests__/registry-helpers.ts b/src/lib/jobs/registry/__tests__/registry-helpers.ts new file mode 100644 index 000000000..eba79f863 --- /dev/null +++ b/src/lib/jobs/registry/__tests__/registry-helpers.ts @@ -0,0 +1,61 @@ +/** + * Reading the worker registry from a test: the declaration of one queue, the + * group that holds it, the handler its loader resolves to, and a group's boot + * discovery passes. Every helper fails loudly on a queue the registry does + * not declare, so a test cannot pass by asserting on nothing. + */ +import type { PgBoss } from "pg-boss"; + +import { JOB_GROUPS } from "../index"; +import type { JobDefinition, JobGroup, JobGroupName } from "../define-job"; + +export function jobOf(queue: string): JobDefinition { + for (const group of JOB_GROUPS) { + const job = group.jobs.find((j) => j.queue === queue); + if (job) return job; + } + throw new Error(`the registry declares no queue "${queue}"`); +} + +export function groupOf(queue: string): JobGroup { + const group = JOB_GROUPS.find((g) => g.jobs.some((j) => j.queue === queue)); + if (!group) throw new Error(`the registry declares no queue "${queue}"`); + return group; +} + +export function groupNamed(name: JobGroupName): JobGroup { + const group = JOB_GROUPS.find((g) => g.name === name); + if (!group) throw new Error(`no registry group "${name}"`); + return group; +} + +/** The names of a group's boot discovery passes, in run order. */ +export function discoveryOf(name: JobGroupName): string[] { + return (groupNamed(name).discovery ?? []).map((pass) => pass.name); +} + +/** + * A boss that records sends and refuses everything else, for loaders that + * close over it (the forwarders, the drain's follow-up). + */ +export function recordingBoss(): { + boss: PgBoss; + sends: Array<{ queue: string; data: unknown; options: unknown }>; +} { + const sends: Array<{ queue: string; data: unknown; options: unknown }> = []; + const boss = { + send: async (queue: string, data: unknown, options: unknown) => { + sends.push({ queue, data, options }); + return `job-${sends.length}`; + }, + } as unknown as PgBoss; + return { boss, sends }; +} + +/** The handler the queue's loader resolves to (its module is loaded). */ +export async function loadedHandler( + queue: string, + boss: PgBoss = recordingBoss().boss, +): Promise { + return jobOf(queue).load({ boss }); +} diff --git a/src/lib/jobs/registry/define-job.ts b/src/lib/jobs/registry/define-job.ts new file mode 100644 index 000000000..acaa67254 --- /dev/null +++ b/src/lib/jobs/registry/define-job.ts @@ -0,0 +1,109 @@ +/** + * The declaration of one pg-boss queue: everything the worker needs to + * provision, schedule and drain it, in one object. + * + * Every queue the worker runs is a `defineJob(...)` entry in one of the + * registry groups (`./integration-sync.ts`, `./status.ts`, `./rollup.ts`, + * `./reminders.ts`, `./maintenance.ts`). `registerJobGroup` turns a group into + * the four facts the dead-queue class keeps losing: the queue exists, its cron + * is scheduled with the send options it carries, its de-duplication policy is + * set, and a handler drains it. A queue cannot be declared without all four. + * + * The handler is not imported here. `load` names the module it lives in with + * a dynamic import, and the worker evaluates it when the queue's first job + * arrives, so booting the worker loads the registry and nothing of the work. + * The facts a declaration needs at boot (names, crons, concurrency, send + * options) therefore come from light modules (`*-queue.ts`, `*-shared.ts`), + * never from a handler module; `job-registry-graph.test.ts` holds that. + */ +import type { Job, PgBoss, WorkOptions } from "pg-boss"; + +import type { JobOutcome } from "@/lib/jobs/job-outcome"; +import type { QueuePolicyDecision } from "@/lib/jobs/reminder/registrar-shared"; + +/** The groups, in the order the worker registers them. */ +export const JOB_GROUP_NAMES = [ + "integration_sync", + "status", + "rollup", + "reminders", + "maintenance", +] as const; + +export type JobGroupName = (typeof JOB_GROUP_NAMES)[number]; + +/** What a handler loader is handed: the worker's own boss, for re-sends. */ +export interface JobContext { + readonly boss: PgBoss; +} + +/** A handler as pg-boss calls it: a batch of jobs, one outcome. */ +export type BatchHandler> = ( + jobs: J[], +) => Promise; + +export interface JobSpec> { + readonly queue: string; + /** The cron that feeds the queue, in the default zone. */ + readonly cron?: string; + /** + * Send options every job the cron mints carries (retries, expiry). They ride + * on the schedule because `createQueue` cannot change an existing queue. + */ + readonly scheduleOptions?: Readonly>; + /** De-duplication policy; absent keeps pg-boss's `standard`. */ + readonly policy?: QueuePolicyDecision; + /** Work options of the binding (concurrency, metadata, poll). */ + readonly work: WorkOptions; + /** + * One run per identity at a time (`lockedPass`), held outside the lane so a + * retry pg-boss starts after an expiry finds the run still going. + */ + readonly lock?: (job: J) => string; + /** + * The job runs in the heavy lane (`heavyLane`): `true` for every job, or a + * filter of the jobs that are heavy on a queue that also carries light ones. + */ + readonly lane?: true | ((job: J) => boolean); + /** Loads the handler that drains the queue. */ + readonly load: (ctx: JobContext) => Promise>; +} + +/** A declaration with its payload type erased, as the registry holds it. */ +export type JobDefinition = JobSpec>; + +export function defineJob>( + spec: JobSpec, +): JobDefinition { + // The payload type is the declaration's own business; the registry only + // passes jobs through to the handler that declared it. + return spec as unknown as JobDefinition; +} + +/** What a boot discovery pass reports, as the backfill helpers return it. */ +export interface DiscoveryResult { + /** How many jobs it enqueued, or whether it enqueued its one job. */ + readonly enqueued: number | boolean; + readonly skipped?: number; + readonly error?: string | null; +} + +/** + * A self-converging pass the worker runs once at boot (after every queue is + * bound): it finds the accounts that still need a backfill or repair and + * enqueues one job each. `name` is the log prefix. + */ +export interface BootDiscovery { + readonly name: string; + readonly run: () => Promise; +} + +export interface JobGroup { + readonly name: JobGroupName; + readonly jobs: readonly JobDefinition[]; + /** Queues an older release created and this one removes, with whatever they hold. */ + readonly retired?: readonly string[]; + /** Work that needs the group's handlers bound, before the boot discovery. */ + readonly afterBind?: (ctx: JobContext) => Promise; + readonly discovery?: readonly BootDiscovery[]; +} diff --git a/src/lib/jobs/registry/index.ts b/src/lib/jobs/registry/index.ts new file mode 100644 index 000000000..a6d02a764 --- /dev/null +++ b/src/lib/jobs/registry/index.ts @@ -0,0 +1,55 @@ +/** + * The worker registry: every queue the worker runs, by group, in the order + * the worker registers them. `reminder-worker.ts` walks it at boot; + * `tests/integration/queue-registration-parity.test.ts` holds what it leaves + * in pg-boss to the golden. + */ +import type { PgBoss } from "pg-boss"; + +import type { JobDefinition, JobGroup } from "./define-job"; +import { INTEGRATION_SYNC_GROUP } from "./integration-sync"; +import { MAINTENANCE_GROUP } from "./maintenance"; +import { REMINDERS_GROUP } from "./reminders"; +import { registerJobGroup, runBootDiscovery } from "./register"; +import { ROLLUP_GROUP } from "./rollup"; +import { STATUS_GROUP } from "./status"; + +export const JOB_GROUPS: readonly JobGroup[] = [ + INTEGRATION_SYNC_GROUP, + STATUS_GROUP, + ROLLUP_GROUP, + REMINDERS_GROUP, + MAINTENANCE_GROUP, +]; + +/** Every definition, across the groups. */ +export function allJobDefinitions(): readonly JobDefinition[] { + return JOB_GROUPS.flatMap((group) => group.jobs); +} + +/** + * Register every group in order. `onGroup` runs before each one, so the boot + * can time the groups as steps. Returns every queue name created. + */ +export async function registerAllJobGroups( + boss: PgBoss, + onGroup: (group: JobGroup) => void = () => {}, +): Promise { + const queues: string[] = []; + for (const group of JOB_GROUPS) { + onGroup(group); + queues.push(...(await registerJobGroup(boss, group))); + } + return queues; +} + +/** + * Run the boot discovery of every group: rollup first, then the integration + * backfills, then maintenance, the order the worker always used. + */ +export async function runAllBootDiscovery(): Promise { + for (const name of ["rollup", "integration_sync", "maintenance"] as const) { + const group = JOB_GROUPS.find((g) => g.name === name); + if (group?.discovery) await runBootDiscovery(group.discovery); + } +} diff --git a/src/lib/jobs/registry/integration-sync.ts b/src/lib/jobs/registry/integration-sync.ts new file mode 100644 index 000000000..d5ec529a3 --- /dev/null +++ b/src/lib/jobs/registry/integration-sync.ts @@ -0,0 +1,391 @@ +/** + * The third-party provider sync queues: Withings, WHOOP, Fitbit, Google + * Health, Nightscout, Polar, Oura, Strava, the full-history backfills and + * repairs that run through the shared admission lane, the integration + * watchdog, and the OAuth-state and handoff prunes. + * + * The recurring polls are keyless cron ticks, so they carry no policy. The + * one-shot backfills and repairs share one shape: one job per connection, + * keyed per user, enqueued by a boot discovery that only stops offering the + * job once it has completed. `exclusive` covers queued, active and + * retry-backoff, so a worker restarting during a long pass cannot append a + * second identical job, and since discovery re-offers outstanding work a + * suppressed send never loses any. + * + * The prunes carry `cronIsTheRetry`: a DELETE that could not run will not run + * better a second later, and the daily tick is the retry. The sync crons keep + * the default retries; a provider outage is transient. + */ +import { + FITBIT_BOOT_DISCOVERY, + FITBIT_JOBS, +} from "@/lib/fitbit/jobs/definitions"; +import { + INTEGRATION_BACKFILL_ADMISSION_QUEUE, + INTEGRATION_BACKFILL_GLOBAL_CONCURRENCY, + bootStaggerSecondsFor, + type IntegrationBackfillKind, +} from "@/lib/jobs/integration-backfill-admission"; +import { + GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_CRON, + GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_QUEUE, + GOOGLE_HEALTH_SYNC_CRON, + GOOGLE_HEALTH_SYNC_QUEUE, + NIGHTSCOUT_SYNC_CRON, + NIGHTSCOUT_SYNC_QUEUE, + OIDC_NATIVE_HANDOFF_CLEANUP_CRON, + OIDC_NATIVE_HANDOFF_CLEANUP_QUEUE, + OURA_SYNC_CRON, + OURA_SYNC_QUEUE, + POLAR_SYNC_CRON, + POLAR_SYNC_QUEUE, + STRAVA_SYNC_CRON, + STRAVA_SYNC_QUEUE, + WHOOP_CYCLE_SYNC_CRON, + WHOOP_CYCLE_SYNC_QUEUE, + WHOOP_OAUTH_STATE_CLEANUP_CRON, + WHOOP_OAUTH_STATE_CLEANUP_QUEUE, + WHOOP_RECOVERY_SYNC_CRON, + WHOOP_RECOVERY_SYNC_QUEUE, + WHOOP_SLEEP_SYNC_CRON, + WHOOP_SLEEP_SYNC_QUEUE, + WHOOP_WORKOUT_SYNC_CRON, + WHOOP_WORKOUT_SYNC_QUEUE, + WITHINGS_ACTIVITY_CRON, + WITHINGS_ACTIVITY_QUEUE, + WITHINGS_ECG_SYNC_CRON, + WITHINGS_OAUTH_STATE_CLEANUP_CRON, + WITHINGS_OAUTH_STATE_CLEANUP_QUEUE, + WITHINGS_SLEEP_CRON, + WITHINGS_SLEEP_QUEUE, + WITHINGS_SYNC_CRON, + WITHINGS_SYNC_QUEUE, +} from "@/lib/jobs/integration-poll-queues"; +import { + INTEGRATION_WATCHDOG_DAILY_CRON, + INTEGRATION_WATCHDOG_DAILY_QUEUE, + INTEGRATION_WATCHDOG_HOURLY_CRON, + INTEGRATION_WATCHDOG_HOURLY_QUEUE, +} from "@/lib/jobs/integration-watchdog-queue"; +import { + GOOGLE_HEALTH_BACKFILL_CONCURRENCY, + GOOGLE_HEALTH_BACKFILL_QUEUE, +} from "@/lib/jobs/google-health-backfill-queue"; +import { + GOOGLE_HEALTH_SLEEP_REPAIR_CONCURRENCY, + GOOGLE_HEALTH_SLEEP_REPAIR_QUEUE, +} from "@/lib/jobs/google-health-sleep-repair-queue"; +import { + LAB_BIOMARKER_BACKFILL_CONCURRENCY, + LAB_BIOMARKER_BACKFILL_QUEUE, +} from "@/lib/jobs/lab-biomarker-backfill"; +import { + OURA_BACKFILL_CONCURRENCY, + OURA_BACKFILL_QUEUE, +} from "@/lib/jobs/oura-backfill-queue"; +import { cronIsTheRetry } from "@/lib/jobs/reminder/registrar-shared"; +import { + SLEEP_TIMELINE_BACKFILL_CONCURRENCY, + SLEEP_TIMELINE_BACKFILL_QUEUE, +} from "@/lib/jobs/sleep-timeline-backfill-queue"; +import { + STRAVA_BACKFILL_CONCURRENCY, + STRAVA_BACKFILL_QUEUE, +} from "@/lib/jobs/strava-backfill-queue"; +import { + WHOOP_BACKFILL_CONCURRENCY, + WHOOP_BACKFILL_QUEUE, +} from "@/lib/jobs/whoop-backfill-queue"; +import { WITHINGS_ECG_SYNC_QUEUE } from "@/lib/jobs/withings-ecg-queue"; + +import { + defineJob, + type BootDiscovery, + type DiscoveryResult, + type JobDefinition, + type JobGroup, +} from "./define-job"; + +const SELF_CONVERGING_BACKFILL = + "Per-connection full-history backfill; boot-discovery driven and self-converging."; + +/** A source queue that forwards each job to the shared admission lane. */ +function backfillSource( + queue: string, + concurrency: number, + kind: IntegrationBackfillKind, + reason: string = SELF_CONVERGING_BACKFILL, +): JobDefinition { + return defineJob({ + queue, + policy: { policy: "exclusive", reason }, + work: { localConcurrency: concurrency }, + load: async ({ boss }) => + ( + await import("@/lib/jobs/integration-backfill-forward") + ).forwardToBackfillAdmission(boss, kind), + }); +} + +const withingsSync = () => import("@/lib/jobs/reminder/withings-sync"); +const whoopSync = () => import("@/lib/jobs/reminder/whoop-sync"); +const googleHealthSync = () => import("@/lib/jobs/reminder/google-health-sync"); +const cleanups = () => import("@/lib/jobs/reminder/cleanup-handlers"); + +const jobs: readonly JobDefinition[] = [ + // pg-boss scopes groupConcurrency to one queue name, so every provider + // reaches this shared queue before doing full-history work. The group is + // enforced in Postgres across workers; the heavy lane keeps an import off + // the heap while a backup, a restore or an upload is on it. + defineJob({ + queue: INTEGRATION_BACKFILL_ADMISSION_QUEUE, + policy: { + policy: "exclusive", + reason: + "Shared full-history drain; keyed per source job so boot rediscovery cannot duplicate queued, active, or retrying work.", + }, + work: { + localConcurrency: INTEGRATION_BACKFILL_GLOBAL_CONCURRENCY, + groupConcurrency: INTEGRATION_BACKFILL_GLOBAL_CONCURRENCY, + }, + lane: true, + load: async () => + (await import("@/lib/jobs/integration-backfill-drain")) + .drainIntegrationBackfillAdmission, + }), + defineJob({ + queue: WITHINGS_SYNC_QUEUE, + cron: WITHINGS_SYNC_CRON, + work: { localConcurrency: 1 }, + load: async () => (await withingsSync()).handleWithingsFallbackSync, + }), + defineJob({ + queue: WITHINGS_ACTIVITY_QUEUE, + cron: WITHINGS_ACTIVITY_CRON, + work: { localConcurrency: 1 }, + load: async () => (await withingsSync()).handleWithingsActivitySync, + }), + defineJob({ + queue: WITHINGS_SLEEP_QUEUE, + cron: WITHINGS_SLEEP_CRON, + work: { localConcurrency: 1 }, + load: async () => (await withingsSync()).handleWithingsSleepSync, + }), + defineJob({ + queue: WITHINGS_ECG_SYNC_QUEUE, + cron: WITHINGS_ECG_SYNC_CRON, + policy: { + policy: "short", + reason: + "Webhook retries share one identity while queued; once work starts, a later replay remains admissible as a rescue if the original exhausts retries.", + }, + work: { localConcurrency: 1 }, + load: async () => (await withingsSync()).handleWithingsEcgSync, + }), + defineJob({ + queue: WITHINGS_OAUTH_STATE_CLEANUP_QUEUE, + cron: WITHINGS_OAUTH_STATE_CLEANUP_CRON, + scheduleOptions: cronIsTheRetry, + work: { localConcurrency: 1 }, + load: async () => (await cleanups()).handleWithingsOAuthStateCleanup, + }), + // WHOOP per-resource syncs: a webhook job is one user, the cron walks + // everyone. Serial, inside WHOOP's 100 requests a minute per app. + defineJob({ + queue: WHOOP_RECOVERY_SYNC_QUEUE, + cron: WHOOP_RECOVERY_SYNC_CRON, + work: { localConcurrency: 1 }, + load: async () => (await whoopSync()).handleWhoopRecoverySync, + }), + defineJob({ + queue: WHOOP_SLEEP_SYNC_QUEUE, + cron: WHOOP_SLEEP_SYNC_CRON, + work: { localConcurrency: 1 }, + load: async () => (await whoopSync()).handleWhoopSleepSync, + }), + defineJob({ + queue: WHOOP_WORKOUT_SYNC_QUEUE, + cron: WHOOP_WORKOUT_SYNC_CRON, + work: { localConcurrency: 1 }, + load: async () => (await whoopSync()).handleWhoopWorkoutSync, + }), + defineJob({ + queue: WHOOP_CYCLE_SYNC_QUEUE, + cron: WHOOP_CYCLE_SYNC_CRON, + work: { localConcurrency: 1 }, + load: async () => (await whoopSync()).handleWhoopCycleSync, + }), + backfillSource( + WHOOP_BACKFILL_QUEUE, + WHOOP_BACKFILL_CONCURRENCY, + "whoop-backfill", + "Per-connection full-history backfill; long-running, and boot discovery re-enqueues until the connection is marked backfilled.", + ), + defineJob({ + queue: WHOOP_OAUTH_STATE_CLEANUP_QUEUE, + cron: WHOOP_OAUTH_STATE_CLEANUP_CRON, + scheduleOptions: cronIsTheRetry, + work: { localConcurrency: 1 }, + load: async () => (await cleanups()).handleWhoopOAuthStateCleanup, + }), + ...FITBIT_JOBS, + defineJob({ + queue: GOOGLE_HEALTH_SYNC_QUEUE, + cron: GOOGLE_HEALTH_SYNC_CRON, + work: { localConcurrency: 1 }, + load: async () => (await googleHealthSync()).handleGoogleHealthSync, + }), + backfillSource( + GOOGLE_HEALTH_BACKFILL_QUEUE, + GOOGLE_HEALTH_BACKFILL_CONCURRENCY, + "google-health-backfill", + ), + backfillSource( + GOOGLE_HEALTH_SLEEP_REPAIR_QUEUE, + GOOGLE_HEALTH_SLEEP_REPAIR_CONCURRENCY, + "google-health-sleep-repair", + "Per-connection one-shot sleep re-read; discovery drops the connection once it is marked repaired.", + ), + defineJob({ + queue: GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_QUEUE, + cron: GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_CRON, + scheduleOptions: cronIsTheRetry, + work: { localConcurrency: 1 }, + load: async () => + (await googleHealthSync()).handleGoogleHealthOAuthStateCleanup, + }), + backfillSource( + SLEEP_TIMELINE_BACKFILL_QUEUE, + SLEEP_TIMELINE_BACKFILL_CONCURRENCY, + "sleep-timeline-backfill", + "Per-connection one-shot timeline backfill, keyed per provider+user; discovery drops the connection once its rows carry the new shape.", + ), + backfillSource( + LAB_BIOMARKER_BACKFILL_QUEUE, + LAB_BIOMARKER_BACKFILL_CONCURRENCY, + "lab-biomarker-backfill", + "Per-user one-shot catalog link-up; discovery drops the user once no unlinked lab readings remain.", + ), + // The cohort polls: the hourly tick (no `userId`) walks every connection; + // one connection's failure is booked on its ledger, not the tick's. + defineJob({ + queue: NIGHTSCOUT_SYNC_QUEUE, + cron: NIGHTSCOUT_SYNC_CRON, + work: { localConcurrency: 1 }, + load: async () => + (await import("@/lib/jobs/reminder/nightscout-sync")) + .handleNightscoutSync, + }), + defineJob({ + queue: POLAR_SYNC_QUEUE, + cron: POLAR_SYNC_CRON, + work: { localConcurrency: 1 }, + load: async () => + (await import("@/lib/jobs/reminder/polar-sync")).handlePolarSync, + }), + defineJob({ + queue: OURA_SYNC_QUEUE, + cron: OURA_SYNC_CRON, + work: { localConcurrency: 1 }, + load: async () => + (await import("@/lib/jobs/reminder/oura-sync")).handleOuraSync, + }), + backfillSource( + OURA_BACKFILL_QUEUE, + OURA_BACKFILL_CONCURRENCY, + "oura-backfill", + ), + defineJob({ + queue: STRAVA_SYNC_QUEUE, + cron: STRAVA_SYNC_CRON, + work: { localConcurrency: 1 }, + load: async () => + (await import("@/lib/jobs/reminder/strava-sync")).handleStravaSync, + }), + backfillSource( + STRAVA_BACKFILL_QUEUE, + STRAVA_BACKFILL_CONCURRENCY, + "strava-backfill", + ), + defineJob({ + queue: OIDC_NATIVE_HANDOFF_CLEANUP_QUEUE, + cron: OIDC_NATIVE_HANDOFF_CLEANUP_CRON, + scheduleOptions: cronIsTheRetry, + work: { localConcurrency: 1 }, + load: async () => (await cleanups()).handleOidcNativeHandoffCleanup, + }), + // The watchdog: an hourly cheap pass and a daily one that re-reads the + // history behind standing silence flags. Both share one advisory lock, so + // they never run side by side on any replica; the next tick is the retry. + defineJob({ + queue: INTEGRATION_WATCHDOG_HOURLY_QUEUE, + cron: INTEGRATION_WATCHDOG_HOURLY_CRON, + scheduleOptions: cronIsTheRetry, + work: { localConcurrency: 1 }, + load: async () => + (await import("@/lib/jobs/integration-watchdog")) + .handleIntegrationWatchdogHourly, + }), + defineJob({ + queue: INTEGRATION_WATCHDOG_DAILY_QUEUE, + cron: INTEGRATION_WATCHDOG_DAILY_CRON, + scheduleOptions: cronIsTheRetry, + work: { localConcurrency: 1 }, + load: async () => + (await import("@/lib/jobs/integration-watchdog")) + .handleIntegrationWatchdogDaily, + }), +]; + +/** A backfill discovery staggered by its kind's place in the boot order. */ +function backfillDiscovery( + kind: IntegrationBackfillKind, + enqueue: (staggerSeconds: number) => Promise, +): BootDiscovery { + return { name: kind, run: () => enqueue(bootStaggerSecondsFor(kind)) }; +} + +const discovery: readonly BootDiscovery[] = [ + backfillDiscovery("whoop-backfill", async (stagger) => + (await import("@/lib/jobs/whoop-backfill")).enqueueBootTimeWhoopBackfill( + stagger, + ), + ), + backfillDiscovery("google-health-backfill", async (stagger) => + ( + await import("@/lib/jobs/google-health-backfill") + ).enqueueBootTimeGoogleHealthBackfill(stagger), + ), + backfillDiscovery("google-health-sleep-repair", async (stagger) => + ( + await import("@/lib/jobs/google-health-sleep-repair") + ).enqueueBootTimeGoogleHealthSleepRepair(stagger), + ), + backfillDiscovery("sleep-timeline-backfill", async (stagger) => + ( + await import("@/lib/jobs/sleep-timeline-backfill") + ).enqueueBootTimeSleepTimelineBackfill(stagger), + ), + backfillDiscovery("strava-backfill", async (stagger) => + (await import("@/lib/jobs/strava-backfill")).enqueueBootTimeStravaBackfill( + stagger, + ), + ), + backfillDiscovery("oura-backfill", async (stagger) => + (await import("@/lib/jobs/oura-backfill")).enqueueBootTimeOuraBackfill( + stagger, + ), + ), + backfillDiscovery("lab-biomarker-backfill", async (stagger) => + ( + await import("@/lib/jobs/lab-biomarker-backfill") + ).enqueueBootTimeLabBiomarkerBackfill(stagger), + ), + ...FITBIT_BOOT_DISCOVERY, +]; + +export const INTEGRATION_SYNC_GROUP: JobGroup = { + name: "integration_sync", + jobs, + discovery, +}; diff --git a/src/lib/jobs/registry/maintenance-discovery.ts b/src/lib/jobs/registry/maintenance-discovery.ts new file mode 100644 index 000000000..aa137ea13 --- /dev/null +++ b/src/lib/jobs/registry/maintenance-discovery.ts @@ -0,0 +1,100 @@ +/** + * The boot discovery of the maintenance group: the duplicate dose-slot + * cleanup, the encryption backfills, the thumbnail backfill, the air-quality + * history, the fold repair and the purge that waits for it, the GeoLite2 + * fetch, and the detached climb backfill. Each pass is idempotent across + * boots; the heavy ones start past the boot storm. + */ +import { BOOT_BACKFILL_STAGGER_SECONDS } from "@/lib/jobs/reminder/shared"; + +import type { BootDiscovery } from "./define-job"; + +export const MAINTENANCE_DISCOVERY: readonly BootDiscovery[] = [ + { + name: "intake-slot-dedup", + run: async () => + ( + await import("@/lib/medications/intake-slot-dedup") + ).enqueueBootTimeIntakeSlotDedup(), + }, + // The heavy loads start after the boot storm; the daily ticks pass no + // offset and stay immediate. + { + name: "note-encryption-backfill", + run: async () => + ( + await import("@/lib/jobs/note-encryption-backfill") + ).enqueueBootTimeNoteEncryptionBackfill( + BOOT_BACKFILL_STAGGER_SECONDS * 5, + ), + }, + { + name: "med-notes-encryption-backfill", + run: async () => + ( + await import("@/lib/jobs/med-notes-encryption-backfill") + ).enqueueBootTimeMedNotesEncryptionBackfill(), + }, + { + name: "free-text-encryption-backfill", + run: async () => + ( + await import("@/lib/jobs/free-text-encryption-backfill") + ).enqueueBootTimeFreeTextEncryptionBackfill( + BOOT_BACKFILL_STAGGER_SECONDS * 6, + ), + }, + { + name: "document-thumbnail-backfill", + run: async () => { + const { enqueued } = await ( + await import("@/lib/jobs/document-thumbnail-backfill") + ).enqueueBootTimeThumbnailBackfill(); + return { enqueued }; + }, + }, + { + name: "environment-aq-history", + run: async () => + ( + await import("@/lib/jobs/environment-air-quality-history") + ).enqueueBootTimeAirQualityHistory(BOOT_BACKFILL_STAGGER_SECONDS * 7), + }, + // Once per account; the purge below waits for it. + { + name: "measurement-fold-repair", + run: async () => + ( + await import("@/lib/jobs/measurement-fold-repair") + ).enqueueBootTimeMeasurementFoldRepair(), + }, + // Self-limiting and idempotent; a boot with no backlog costs one query. + { + name: "compaction-tombstone-purge", + run: async () => { + const { enqueued } = await ( + await import("@/lib/jobs/compaction-tombstone-purge") + ).enqueueBootTimeCompactionTombstonePurge(); + return { enqueued }; + }, + }, + // Only with a MaxMind key, egress allowed and no offline database present. + { + name: "geolite2-fetch", + run: async () => { + const { enqueued } = await ( + await import("@/lib/jobs/geolite2-fetch") + ).enqueueGeolite2FetchBootDiscovery(); + return { enqueued }; + }, + }, + // Detached: reads one track at a time with a pause between batches and + // resumes from its cursor on the next boot; a finished pass returns at once. + { + name: "workout-climb-backfill", + run: async () => + ( + await import("@/lib/jobs/reminder/climb-backfill-at-boot") + ).startClimbBackfill(), + }, +]; diff --git a/src/lib/jobs/registry/maintenance.ts b/src/lib/jobs/registry/maintenance.ts new file mode 100644 index 000000000..d0f2ec23e --- /dev/null +++ b/src/lib/jobs/registry/maintenance.ts @@ -0,0 +1,744 @@ +/** + * The housekeeping queues: backups (weekly in-database, nightly off-host), + * the restore drill and restores, every ledger and retention cleanup, the + * medication maintenance passes, the imports (Apple Health, Health Connect, + * medication intake), the converging encryption backfills, the document vault + * work, the environment and Dawarich context, the geo and TLS probes, PR + * detection and the measurement table maintenance. + * + * `cronIsTheRetry` (no retries) marks the passes whose failure repeats: a bulk + * DELETE or UPDATE that timed out will time out again on an immediate retry, + * and the next tick arrives with the whole gap as backoff. The transient ones + * (the TLS probe, the off-host upload, a provider fetch) keep the default + * retries. The restore drill sends `retryLimit: 0` in its own options, because + * it pages each failed account itself. + * + * Policies. The retention ticks are keyless, so they carry none. Deliberately + * left at `standard`: the environment fetch (an explicit-range backfill sends + * with no key so it always runs; a policy would merge two requested ranges), + * and the imports, the backup and PR detection, whose runs are each distinct + * work. The per-user converging backfills are `exclusive`; the per-document + * jobs and the runs that queue their own follow-up while active are `short`. + */ +import type { Job } from "pg-boss"; + +import { + ACHIEVEMENT_UNLOCK_SWEEP_CRON, + ACHIEVEMENT_UNLOCK_SWEEP_QUEUE, +} from "@/lib/jobs/achievement-unlock-sweep-queue"; +import { + APPLE_HEALTH_IMPORT_CONCURRENCY, + APPLE_HEALTH_IMPORT_LEGACY_QUEUE, + APPLE_HEALTH_IMPORT_V2_QUEUE, + IMPORT_JOB_RECONCILE_CRON, + IMPORT_JOB_RECONCILE_QUEUE, +} from "@/lib/jobs/apple-health-import-queue"; +import { + AUTH_LEDGER_CLEANUP_CRON, + AUTH_LEDGER_CLEANUP_QUEUE, +} from "@/lib/jobs/auth-ledger-cleanup"; +import { BACKUP_RESTORE_QUEUE } from "@/lib/jobs/backup-restore-queue"; +import { COMPACTION_TOMBSTONE_PURGE_QUEUE } from "@/lib/jobs/compaction-tombstone-purge-queue"; +import { + CYCLE_PREDICTION_REFRESH_CRON, + CYCLE_PREDICTION_REFRESH_QUEUE, +} from "@/lib/jobs/cycle-prediction-refresh"; +import { + DATA_BACKUP_QUEUE, + DATA_BACKUP_SEND_OPTIONS, +} from "@/lib/jobs/data-backup-policy"; +import { + DAWARICH_SYNC_CRON, + DAWARICH_SYNC_QUEUE, +} from "@/lib/jobs/dawarich-sync-queue"; +import { + DOCUMENT_AI_RUN_CONCURRENCY, + DOCUMENT_AI_RUN_QUEUE, +} from "@/lib/jobs/document-ai-run-queue"; +import { + DOCUMENT_AI_RUN_REAPER_CRON, + DOCUMENT_AI_RUN_REAPER_QUEUE, +} from "@/lib/jobs/document-ai-run-reaper-queue"; +import { + CONTENT_INDEX_BACKFILL_CONCURRENCY, + CONTENT_INDEX_BACKFILL_QUEUE, +} from "@/lib/jobs/document-content-index-backfill-queue"; +import { + DOCUMENT_INDEX_CONCURRENCY, + DOCUMENT_INDEX_QUEUE, +} from "@/lib/jobs/document-index-queue"; +import { + DOCUMENT_PURGE_CRON, + DOCUMENT_PURGE_QUEUE, +} from "@/lib/jobs/document-purge"; +import { + DOCUMENT_SUMMARY_CATCHUP_CONCURRENCY, + DOCUMENT_SUMMARY_CATCHUP_QUEUE, +} from "@/lib/jobs/document-summary-catchup-queue"; +import { + DOCUMENT_SUMMARY_CONCURRENCY, + DOCUMENT_SUMMARY_QUEUE, +} from "@/lib/jobs/document-summary-queue"; +import { + DOCUMENT_SUMMARY_REAPER_CRON, + DOCUMENT_SUMMARY_REAPER_QUEUE, +} from "@/lib/jobs/document-summary-reaper-queue"; +import { + DOCUMENT_THUMBNAIL_CONCURRENCY, + DOCUMENT_THUMBNAIL_QUEUE, +} from "@/lib/jobs/document-thumbnail-queue"; +import { + DOCUMENT_THUMBNAIL_BACKFILL_CONCURRENCY, + DOCUMENT_THUMBNAIL_BACKFILL_QUEUE, +} from "@/lib/jobs/document-thumbnail-backfill-queue"; +import { + ENCRYPTION_KEY_ROTATE_CONCURRENCY, + ENCRYPTION_KEY_ROTATE_QUEUE, +} from "@/lib/jobs/encryption-key-rotate-queue"; +import { + ENVIRONMENT_AQ_HISTORY_CRON, + ENVIRONMENT_AQ_HISTORY_QUEUE, +} from "@/lib/jobs/environment-air-quality-history"; +import { + ENVIRONMENT_FETCH_CONCURRENCY, + ENVIRONMENT_FETCH_CRON, + ENVIRONMENT_FETCH_QUEUE, +} from "@/lib/jobs/environment-fetch"; +import { + FREE_TEXT_ENCRYPTION_BACKFILL_CONCURRENCY, + FREE_TEXT_ENCRYPTION_BACKFILL_QUEUE, +} from "@/lib/jobs/free-text-encryption-backfill"; +import { GEO_BACKFILL_CRON, GEO_BACKFILL_QUEUE } from "@/lib/jobs/geo-backfill"; +import { + GEOLITE2_FETCH_CRON, + GEOLITE2_FETCH_QUEUE, +} from "@/lib/jobs/geolite2-fetch"; +import { + HEALTH_CONNECT_IMPORT_CONCURRENCY, + HEALTH_CONNECT_IMPORT_QUEUE, +} from "@/lib/jobs/health-connect-import-queue"; +import { + INTAKE_AUTO_SKIP_CRON, + INTAKE_AUTO_SKIP_QUEUE, +} from "@/lib/jobs/intake-auto-skip-queue"; +import { + INTAKE_AUTO_TAKE_CRON, + INTAKE_AUTO_TAKE_QUEUE, +} from "@/lib/jobs/intake-auto-take-queue"; +import { WHOLE_PASS } from "@/lib/jobs/long-pass"; +import { + MED_NOTES_ENCRYPTION_BACKFILL_CONCURRENCY, + MED_NOTES_ENCRYPTION_BACKFILL_QUEUE, +} from "@/lib/jobs/med-notes-encryption-backfill"; +import { MEASUREMENT_FOLD_REPAIR_QUEUE } from "@/lib/jobs/measurement-fold-repair-queue"; +import { + MEASUREMENT_MAINTENANCE_CRON, + MEASUREMENT_MAINTENANCE_QUEUE, + MEASUREMENT_MAINTENANCE_SEND_OPTIONS, +} from "@/lib/jobs/measurement-maintenance"; +import { + MEDICATION_INTAKE_IMPORT_CONCURRENCY, + MEDICATION_INTAKE_IMPORT_QUEUE, +} from "@/lib/jobs/medication-intake-import-queue"; +import { + MEDICATION_INVENTORY_EXPIRE_CRON, + MEDICATION_INVENTORY_EXPIRE_QUEUE, +} from "@/lib/jobs/medication-inventory-expire"; +import { + MOOD_PROGNOSIS_REFRESH_CRON, + MOOD_PROGNOSIS_REFRESH_QUEUE, +} from "@/lib/jobs/mood-prognosis-refresh-queue"; +import { + NOTE_ENCRYPTION_BACKFILL_CONCURRENCY, + NOTE_ENCRYPTION_BACKFILL_QUEUE, +} from "@/lib/jobs/note-encryption-backfill"; +import { + OFFHOST_BACKUP_QUEUE, + OFFHOST_BACKUP_SEND_OPTIONS, +} from "@/lib/jobs/offhost-backup-queue"; +import { + OFFHOST_PURGE_CRON, + OFFHOST_PURGE_QUEUE, +} from "@/lib/jobs/offhost-purge-queue"; +import { + PR_DETECTION_CONCURRENCY, + PR_DETECTION_EXPIRE_SECONDS, + PR_DETECTION_FALLBACK_CRON, + PR_DETECTION_QUEUE, + type PrDetectionPayload, +} from "@/lib/jobs/pr-detection"; +import { PULSE_DAY_MEAN_QUEUE } from "@/lib/jobs/pulse-day-mean"; +import { cronIsTheRetry } from "@/lib/jobs/reminder/registrar-shared"; +import { + RESTORE_DRILL_CRON, + RESTORE_DRILL_QUEUE, + RESTORE_DRILL_SEND_OPTIONS, +} from "@/lib/jobs/restore-drill-queue"; +import { + SOFT_DELETE_PURGE_CRON, + SOFT_DELETE_PURGE_QUEUE, +} from "@/lib/jobs/soft-delete-purge"; +import { + TLS_PIN_MONITOR_CRON, + TLS_PIN_MONITOR_QUEUE, +} from "@/lib/jobs/tls-pin-monitor-queue"; +import { + INTAKE_SLOT_DEDUP_CONCURRENCY, + INTAKE_SLOT_DEDUP_QUEUE, +} from "@/lib/medications/intake-slot-dedup"; + +import { MAINTENANCE_DISCOVERY } from "./maintenance-discovery"; +import { + defineJob, + type JobDefinition, + type JobGroup, + type JobSpec, +} from "./define-job"; + +/** Weekly, Sunday 03:00. The send options carry the backup's long expiry. */ +const DATA_BACKUP_CRON = "0 3 * * 0"; +/** 02:30: after the audit and idempotency cleanups, before the weekly backup. */ +const OFFHOST_BACKUP_CRON = "30 2 * * *"; +const RATE_LIMIT_CLEANUP_QUEUE = "rate-limit-cleanup"; +const RATE_LIMIT_CLEANUP_CRON = "*/5 * * * *"; +const IDEMPOTENCY_CLEANUP_QUEUE = "idempotency-cleanup"; +const IDEMPOTENCY_CLEANUP_CRON = "0 3 * * *"; +/** An elevation lives five minutes; the sweep only keeps the table small. */ +const STEP_UP_ELEVATION_CLEANUP_QUEUE = "step-up-elevation-cleanup"; +const STEP_UP_ELEVATION_CLEANUP_CRON = "*/15 * * * *"; +const AUDIT_LOG_CLEANUP_QUEUE = "audit-log-cleanup"; +const AUDIT_LOG_CLEANUP_CRON = "15 3 * * *"; +/** Daily duplicate dose-slot discovery; the empty payload is the fan-out. */ +const INTAKE_SLOT_DEDUP_CRON = "28 3 * * *"; +/** Every five minutes; the admin chart polls at the same interval. */ +const HOST_METRIC_QUEUE = "host-metric-sample"; +const HOST_METRIC_CRON = "*/5 * * * *"; +/** After the cleanups, so the previous day's noise is gone. */ +const FEEDBACK_AGGREGATOR_QUEUE = "feedback-aggregator"; +const FEEDBACK_AGGREGATOR_CRON = "0 4 * * *"; +const MOOD_REMINDER_CLEANUP_QUEUE = "mood-reminder-cleanup"; +const MOOD_REMINDER_CLEANUP_CRON = "25 3 * * *"; +const PUSH_ATTEMPT_CLEANUP_QUEUE = "push-attempt-cleanup"; +const PUSH_ATTEMPT_CLEANUP_CRON = "35 3 * * *"; +const ARRIVAL_REACTION_CLEANUP_QUEUE = "arrival-reaction-cleanup"; +const ARRIVAL_REACTION_CLEANUP_CRON = "10 4 * * *"; +const MEASUREMENT_TOMBSTONE_CLEANUP_QUEUE = "measurement-tombstone-cleanup"; +const MEASUREMENT_TOMBSTONE_CLEANUP_CRON = "40 3 * * *"; +const COACH_MESSAGE_CLEANUP_QUEUE = "coach-message-cleanup"; +const COACH_MESSAGE_CLEANUP_CRON = "50 3 * * *"; +const WORKOUT_INSIGHT_CLAIM_CLEANUP_QUEUE = "workout-insight-claim-cleanup"; +const WORKOUT_INSIGHT_CLAIM_CLEANUP_CRON = "52 3 * * *"; +const NOTE_ENCRYPTION_BACKFILL_CRON = "55 3 * * *"; +const MCP_TOKEN_CLEANUP_QUEUE = "mcp-token-cleanup"; +const MCP_TOKEN_CLEANUP_CRON = "0 4 * * *"; +const MED_NOTES_ENCRYPTION_BACKFILL_CRON = "5 4 * * *"; +const FREE_TEXT_ENCRYPTION_BACKFILL_CRON = "7 4 * * *"; + +const backups = () => import("@/lib/jobs/reminder/backup-handlers"); +const cleanups = () => import("@/lib/jobs/reminder/cleanup-handlers"); +const ops = () => import("@/lib/jobs/reminder/ops-handlers"); +const medication = () => import("@/lib/jobs/reminder/medication-maintenance"); +const maintenance = () => import("@/lib/jobs/reminder/maintenance-handlers"); +const encryption = () => + import("@/lib/jobs/reminder/encryption-backfill-handlers"); +const documents = () => import("@/lib/jobs/reminder/document-handlers"); + +/** A daily keyless prune: one bounded, idempotent delete, single-flight. */ +function prune>( + queue: string, + cron: string, + load: JobSpec["load"], + options: { retry: "cron" | "default" } = { retry: "cron" }, +): JobDefinition { + return defineJob({ + queue, + cron, + ...(options.retry === "cron" ? { scheduleOptions: cronIsTheRetry } : {}), + work: { localConcurrency: 1 }, + load, + }); +} + +const perUserBackfill = (reason: string) => + ({ policy: "exclusive", reason }) as const; + +const jobs: readonly JobDefinition[] = [ + defineJob({ + queue: ENVIRONMENT_FETCH_QUEUE, + cron: ENVIRONMENT_FETCH_CRON, + work: { localConcurrency: ENVIRONMENT_FETCH_CONCURRENCY }, + load: async ({ boss }) => + (await maintenance()).environmentFetchHandler(boss), + }), + // A run with work left sends its own follow-up while still active, which + // `exclusive` would swallow. + defineJob({ + queue: ENVIRONMENT_AQ_HISTORY_QUEUE, + cron: ENVIRONMENT_AQ_HISTORY_CRON, + policy: { + policy: "short", + reason: + "Per-account singleton key; collapse queued duplicates only, so the active run can queue its own follow-up.", + }, + work: { localConcurrency: 1 }, + load: async ({ boss }) => + (await maintenance()).environmentAqHistoryHandler(boss), + }), + // A failed run is an outage at the instance; the default retries apply. + defineJob({ + queue: DAWARICH_SYNC_QUEUE, + cron: DAWARICH_SYNC_CRON, + work: { localConcurrency: 1 }, + load: async ({ boss }) => (await maintenance()).dawarichSyncHandler(boss), + }), + defineJob({ + queue: DATA_BACKUP_QUEUE, + cron: DATA_BACKUP_CRON, + scheduleOptions: DATA_BACKUP_SEND_OPTIONS, + work: { localConcurrency: 1 }, + lock: () => WHOLE_PASS, + lane: true, + load: async () => (await backups()).handleDataBackup, + }), + prune( + RATE_LIMIT_CLEANUP_QUEUE, + RATE_LIMIT_CLEANUP_CRON, + async () => (await cleanups()).handleRateLimitCleanup, + ), + prune( + IDEMPOTENCY_CLEANUP_QUEUE, + IDEMPOTENCY_CLEANUP_CRON, + async () => (await cleanups()).handleIdempotencyCleanup, + ), + prune( + AUDIT_LOG_CLEANUP_QUEUE, + AUDIT_LOG_CLEANUP_CRON, + async () => (await cleanups()).handleAuditLogCleanup, + { retry: "default" }, + ), + prune( + STEP_UP_ELEVATION_CLEANUP_QUEUE, + STEP_UP_ELEVATION_CLEANUP_CRON, + async () => (await cleanups()).handleStepUpElevationCleanup, + ), + // Bound with metadata: a retry resumes from the job's creation time. + defineJob({ + queue: OFFHOST_BACKUP_QUEUE, + cron: OFFHOST_BACKUP_CRON, + scheduleOptions: OFFHOST_BACKUP_SEND_OPTIONS, + work: { localConcurrency: 1, includeMetadata: true }, + lock: () => WHOLE_PASS, + lane: true, + load: async () => (await backups()).handleOffhostBackup, + }), + // Locked: a drill that outlives a restart never reads the bucket beside + // another one. + defineJob({ + queue: RESTORE_DRILL_QUEUE, + cron: RESTORE_DRILL_CRON, + scheduleOptions: RESTORE_DRILL_SEND_OPTIONS, + work: { localConcurrency: 1 }, + lock: () => WHOLE_PASS, + lane: true, + load: async () => + (await import("@/lib/jobs/restore-drill")).handleRestoreDrill, + }), + // The nightly backstop for the purge an account deletion kicks at once. + prune( + OFFHOST_PURGE_QUEUE, + OFFHOST_PURGE_CRON, + async () => (await import("@/lib/jobs/offhost-purge")).handleOffhostPurge, + ), + // One restore at a time: each holds a transaction over a whole account. + defineJob({ + queue: BACKUP_RESTORE_QUEUE, + work: { localConcurrency: 1 }, + lane: true, + load: async () => + (await import("@/lib/jobs/backup-restore")).handleBackupRestore, + }), + prune( + HOST_METRIC_QUEUE, + HOST_METRIC_CRON, + async () => (await ops()).handleHostMetricSample, + ), + prune( + FEEDBACK_AGGREGATOR_QUEUE, + FEEDBACK_AGGREGATOR_CRON, + async () => (await ops()).handleFeedbackAggregator, + ), + // Hourly at :40, capped per pass, for audit rows that landed without a geo. + prune( + GEO_BACKFILL_QUEUE, + GEO_BACKFILL_CRON, + async () => (await ops()).handleGeoBackfill, + ), + // Monthly; a month is far too long to be the retry, so default retries. + prune( + GEOLITE2_FETCH_QUEUE, + GEOLITE2_FETCH_CRON, + async () => (await ops()).handleGeolite2Fetch, + { retry: "default" }, + ), + // Every six hours, inside the re-pin window the app's leaf pin needs. + prune( + TLS_PIN_MONITOR_QUEUE, + TLS_PIN_MONITOR_CRON, + async () => (await ops()).handleTlsPinMonitor, + { retry: "default" }, + ), + // An ingest job is one account; the fallback rescan every 30 minutes walks + // every account, one run at a time. + defineJob({ + queue: PR_DETECTION_QUEUE, + cron: PR_DETECTION_FALLBACK_CRON, + scheduleOptions: { expireInSeconds: PR_DETECTION_EXPIRE_SECONDS }, + work: { localConcurrency: PR_DETECTION_CONCURRENCY }, + lock: (job: Job) => { + const userId = (job.data as PrDetectionPayload | undefined)?.userId; + return userId ? `user:${userId}` : WHOLE_PASS; + }, + load: async () => (await ops()).handlePrDetection, + }), + prune( + MEDICATION_INVENTORY_EXPIRE_QUEUE, + MEDICATION_INVENTORY_EXPIRE_CRON, + async () => (await medication()).handleMedicationInventoryExpire, + ), + prune( + INTAKE_AUTO_SKIP_QUEUE, + INTAKE_AUTO_SKIP_CRON, + async () => (await medication()).handleIntakeAutoSkip, + ), + prune( + INTAKE_AUTO_TAKE_QUEUE, + INTAKE_AUTO_TAKE_CRON, + async () => (await medication()).handleIntakeAutoTake, + ), + // CPU-bound parse of up to a gigabyte; a second at once would race the + // first for memory. + defineJob({ + queue: APPLE_HEALTH_IMPORT_V2_QUEUE, + work: { localConcurrency: APPLE_HEALTH_IMPORT_CONCURRENCY }, + lane: true, + load: async () => (await maintenance()).handleAppleHealthImportJobs, + }), + defineJob({ + queue: APPLE_HEALTH_IMPORT_LEGACY_QUEUE, + work: { localConcurrency: APPLE_HEALTH_IMPORT_CONCURRENCY }, + load: async () => (await maintenance()).handleLegacyAppleHealthImportJobs, + }), + // Every 15 minutes: an import a crashed worker left mid-run converges to a + // visible `failed` within two ticks. + prune( + IMPORT_JOB_RECONCILE_QUEUE, + IMPORT_JOB_RECONCILE_CRON, + async () => + (await import("@/lib/jobs/apple-health-import-worker")) + .handleImportJobReconcileTick, + ), + defineJob({ + queue: MEDICATION_INTAKE_IMPORT_QUEUE, + work: { + localConcurrency: MEDICATION_INTAKE_IMPORT_CONCURRENCY, + includeMetadata: true, + }, + load: async () => (await maintenance()).handleMedicationIntakeImportJobs, + }), + defineJob({ + queue: HEALTH_CONNECT_IMPORT_QUEUE, + work: { localConcurrency: HEALTH_CONNECT_IMPORT_CONCURRENCY }, + lane: true, + load: async () => + (await import("@/lib/jobs/health-connect-import-worker")) + .handleHealthConnectImport, + }), + // A run that stops early sends its own follow-up while still active, which + // `exclusive` would swallow. Serial: each batch holds an account against a + // concurrent restore. + defineJob({ + queue: COMPACTION_TOMBSTONE_PURGE_QUEUE, + policy: { + policy: "short", + reason: + "Fixed singleton key; collapse queued duplicates only, so the active run can queue its own follow-up.", + }, + work: { localConcurrency: 1 }, + load: async () => + (await import("@/lib/jobs/compaction-tombstone-purge")) + .handleCompactionTombstonePurge, + }), + defineJob({ + queue: MEASUREMENT_FOLD_REPAIR_QUEUE, + policy: { + policy: "short", + reason: + "Per-account singleton key; collapse queued duplicates only, so the active run can queue its own follow-up.", + }, + work: { localConcurrency: 1 }, + load: async () => + (await import("@/lib/jobs/measurement-fold-repair")) + .handleMeasurementFoldRepair, + }), + // The nightly check vacuums when dead rows pile up; the same singleton key + // as the operator's run, so the two collapse. + defineJob({ + queue: MEASUREMENT_MAINTENANCE_QUEUE, + cron: MEASUREMENT_MAINTENANCE_CRON, + scheduleOptions: MEASUREMENT_MAINTENANCE_SEND_OPTIONS, + policy: { + policy: "exclusive", + reason: + "Fixed singleton key on the admin send and the nightly tick alike. Two maintenance passes would vacuum or rebuild side by side; the route reports a suppressed send back as enqueued: false.", + }, + work: { localConcurrency: 1 }, + load: async () => + (await import("@/lib/jobs/measurement-maintenance")) + .handleMeasurementMaintenance, + }), + defineJob({ + queue: PULSE_DAY_MEAN_QUEUE, + policy: { + policy: "exclusive", + reason: + "Fixed singleton key, admin-triggered. A dry run beside a rebuild would compare against rollups being rewritten; the route answers 409 busy instead.", + }, + work: { localConcurrency: 1 }, + load: async () => + (await import("@/lib/jobs/pulse-day-mean")).handlePulseDayMean, + }), + defineJob({ + queue: INTAKE_SLOT_DEDUP_QUEUE, + cron: INTAKE_SLOT_DEDUP_CRON, + policy: perUserBackfill( + "Per-user duplicate-slot cleanup; discovery drops the user once no colliding intake pair remains.", + ), + work: { localConcurrency: INTAKE_SLOT_DEDUP_CONCURRENCY }, + load: async () => (await maintenance()).handleIntakeSlotDedup, + }), + prune( + MOOD_REMINDER_CLEANUP_QUEUE, + MOOD_REMINDER_CLEANUP_CRON, + async () => (await cleanups()).handleMoodReminderCleanup, + ), + prune( + PUSH_ATTEMPT_CLEANUP_QUEUE, + PUSH_ATTEMPT_CLEANUP_CRON, + async () => (await cleanups()).handlePushAttemptCleanup, + ), + prune( + ARRIVAL_REACTION_CLEANUP_QUEUE, + ARRIVAL_REACTION_CLEANUP_CRON, + async () => (await cleanups()).handleArrivalReactionCleanup, + ), + // The nightly refreshes: tomorrow's tick is the retry. + prune( + CYCLE_PREDICTION_REFRESH_QUEUE, + CYCLE_PREDICTION_REFRESH_CRON, + async () => + (await import("@/lib/jobs/cycle-prediction-refresh")) + .handleCyclePredictionRefresh, + ), + prune( + MOOD_PROGNOSIS_REFRESH_QUEUE, + MOOD_PROGNOSIS_REFRESH_CRON, + async () => + (await import("@/lib/jobs/mood-prognosis-refresh")) + .handleMoodPrognosisRefresh, + ), + prune( + ACHIEVEMENT_UNLOCK_SWEEP_QUEUE, + ACHIEVEMENT_UNLOCK_SWEEP_CRON, + async () => + (await import("@/lib/jobs/achievement-unlock-sweep")) + .handleAchievementUnlockSweep, + ), + prune( + MEASUREMENT_TOMBSTONE_CLEANUP_QUEUE, + MEASUREMENT_TOMBSTONE_CLEANUP_CRON, + async () => (await cleanups()).handleMeasurementTombstoneCleanup, + { retry: "default" }, + ), + prune( + COACH_MESSAGE_CLEANUP_QUEUE, + COACH_MESSAGE_CLEANUP_CRON, + async () => (await cleanups()).handleCoachMessageCleanup, + ), + prune( + WORKOUT_INSIGHT_CLAIM_CLEANUP_QUEUE, + WORKOUT_INSIGHT_CLAIM_CLEANUP_CRON, + async () => (await cleanups()).handleWorkoutInsightClaimCleanup, + ), + // The converging encryption backfills: boot discovery plus a daily tick + // whose empty payload fans out one job per account still holding a + // readable value. + defineJob({ + queue: NOTE_ENCRYPTION_BACKFILL_QUEUE, + cron: NOTE_ENCRYPTION_BACKFILL_CRON, + policy: perUserBackfill( + "Per-user note-encryption backfill; discovery drops the user once no plaintext note remains.", + ), + work: { localConcurrency: NOTE_ENCRYPTION_BACKFILL_CONCURRENCY }, + load: async () => (await encryption()).handleNoteEncryptionBackfill, + }), + // Admin-triggered, one corpus rotation at a time. + defineJob({ + queue: ENCRYPTION_KEY_ROTATE_QUEUE, + policy: { + policy: "exclusive", + reason: + "Fixed singleton key, admin-triggered. Two concurrent corpus rotations must never overlap; the route already reports a suppressed send back as alreadyQueued.", + }, + work: { localConcurrency: ENCRYPTION_KEY_ROTATE_CONCURRENCY }, + lock: () => WHOLE_PASS, + load: async () => + (await import("@/lib/jobs/encryption-key-rotate")) + .handleEncryptionKeyRotate, + }), + prune( + MCP_TOKEN_CLEANUP_QUEUE, + MCP_TOKEN_CLEANUP_CRON, + async () => (await cleanups()).handleMcpTokenCleanup, + ), + defineJob({ + queue: MED_NOTES_ENCRYPTION_BACKFILL_QUEUE, + cron: MED_NOTES_ENCRYPTION_BACKFILL_CRON, + policy: perUserBackfill( + "Per-user medication-note encryption backfill; discovery drops the user once no plaintext note remains.", + ), + work: { localConcurrency: MED_NOTES_ENCRYPTION_BACKFILL_CONCURRENCY }, + load: async () => (await encryption()).handleMedNotesEncryptionBackfill, + }), + defineJob({ + queue: FREE_TEXT_ENCRYPTION_BACKFILL_QUEUE, + cron: FREE_TEXT_ENCRYPTION_BACKFILL_CRON, + policy: perUserBackfill( + "Per-user encryption backfill (Coach title, custom-metric note, practitioner phone + address, workout GPS track); discovery drops the user once no readable value remains.", + ), + work: { localConcurrency: FREE_TEXT_ENCRYPTION_BACKFILL_CONCURRENCY }, + load: async () => (await encryption()).handleFreeTextEncryptionBackfill, + }), + // Physical purge of documents past the 30-day undo grace. + prune( + DOCUMENT_PURGE_QUEUE, + DOCUMENT_PURGE_CRON, + async () => (await import("@/lib/jobs/document-purge")).handleDocumentPurge, + ), + prune( + SOFT_DELETE_PURGE_QUEUE, + SOFT_DELETE_PURGE_CRON, + async () => + (await import("@/lib/jobs/soft-delete-purge")).handleSoftDeletePurge, + ), + prune( + AUTH_LEDGER_CLEANUP_QUEUE, + AUTH_LEDGER_CLEANUP_CRON, + async () => + (await import("@/lib/jobs/auth-ledger-cleanup")).handleAuthLedgerCleanup, + ), + // Hourly heal of summaries stuck on PENDING. + prune( + DOCUMENT_SUMMARY_REAPER_QUEUE, + DOCUMENT_SUMMARY_REAPER_CRON, + async () => + (await import("@/lib/jobs/document-summary-reaper")) + .handleDocumentSummaryReaper, + ), + // Index-all for one account, idempotent: a second press while one runs is + // correctly a no-op. + defineJob({ + queue: CONTENT_INDEX_BACKFILL_QUEUE, + policy: { + policy: "exclusive", + reason: + "Per-user index-all run, triggered from the documents surface. Idempotent (it indexes only not-yet-indexed documents), so a second press while one runs is correctly a no-op.", + }, + work: { localConcurrency: CONTENT_INDEX_BACKFILL_CONCURRENCY }, + lock: (job: Job<{ userId?: string }>) => `user:${job.data.userId ?? ""}`, + load: async () => (await documents()).handleContentIndexBackfill, + }), + // Per-document jobs re-read the document when they start, so only queued + // duplicates may collapse; nothing re-offers a dropped re-process. + defineJob({ + queue: DOCUMENT_INDEX_QUEUE, + policy: { + policy: "short", + reason: + "Per-document, no re-converging discovery pass. Collapse queued duplicates only, so a re-index after a content change is never dropped.", + }, + work: { localConcurrency: DOCUMENT_INDEX_CONCURRENCY }, + load: async () => (await documents()).handleDocumentIndex, + }), + // One run per job under its run id's lock, so a redelivery never reads + // beside the first delivery. + defineJob({ + queue: DOCUMENT_AI_RUN_QUEUE, + work: { localConcurrency: DOCUMENT_AI_RUN_CONCURRENCY }, + lock: (job: Job<{ runId?: string }>) => `run:${job.data.runId ?? ""}`, + load: async () => + (await import("@/lib/jobs/document-ai-run")).handleDocumentAiRunJobs, + }), + prune( + DOCUMENT_AI_RUN_REAPER_QUEUE, + DOCUMENT_AI_RUN_REAPER_CRON, + async () => + (await import("@/lib/jobs/document-ai-run-reaper")) + .handleDocumentAiRunReaper, + ), + defineJob({ + queue: DOCUMENT_THUMBNAIL_QUEUE, + policy: { + policy: "short", + reason: + "Per-document, no re-converging discovery pass. Collapse queued duplicates only.", + }, + work: { localConcurrency: DOCUMENT_THUMBNAIL_CONCURRENCY }, + load: async () => (await documents()).handleDocumentThumbnail, + }), + defineJob({ + queue: DOCUMENT_THUMBNAIL_BACKFILL_QUEUE, + policy: perUserBackfill( + "Per-user thumbnail backfill; discovery drops the user once every thumbnailable document has a preview.", + ), + work: { localConcurrency: DOCUMENT_THUMBNAIL_BACKFILL_CONCURRENCY }, + load: async () => (await documents()).handleDocumentThumbnailBackfill, + }), + defineJob({ + queue: DOCUMENT_SUMMARY_QUEUE, + policy: { + policy: "short", + reason: + "Per-document, no re-converging discovery pass. Collapse queued duplicates only.", + }, + work: { localConcurrency: DOCUMENT_SUMMARY_CONCURRENCY }, + load: async () => (await documents()).handleDocumentSummary, + }), + defineJob({ + queue: DOCUMENT_SUMMARY_CATCHUP_QUEUE, + policy: { + policy: "short", + reason: + "Per-user, keyed on the opt-in flip. Collapse a double toggle into one pass; the pass re-reads its candidate set when it starts.", + }, + work: { localConcurrency: DOCUMENT_SUMMARY_CATCHUP_CONCURRENCY }, + load: async () => (await documents()).handleDocumentSummaryCatchUp, + }), +]; + +/** A restore the previous process was running is picked up once its heartbeat is stale. */ +async function sweepInterruptedRestores({ + boss, +}: { + boss: Parameters[0]["boss"]; +}): Promise { + await ( + await import("@/lib/jobs/backup-restore") + ).enqueueBackupRestoreSweepAtBoot(boss); +} + +export const MAINTENANCE_GROUP: JobGroup = { + name: "maintenance", + jobs, + afterBind: sweepInterruptedRestores, + discovery: MAINTENANCE_DISCOVERY, +}; diff --git a/src/lib/jobs/registry/register.ts b/src/lib/jobs/registry/register.ts new file mode 100644 index 000000000..3a2a36bef --- /dev/null +++ b/src/lib/jobs/registry/register.ts @@ -0,0 +1,146 @@ +/** + * Turn the registry into running queues. + * + * For one group, in this order: create every queue (policy and retention + * included), reconcile the policies of queues that already exist, schedule + * every cron, remove the retired queues, then bind a handler to every queue. + * The order is the one the hand-written registrars kept, and + * `tests/integration/queue-registration-parity.test.ts` holds what it leaves + * in pg-boss to the golden they produced. + * + * A handler is loaded on its queue's first job, not here. The wrappers that + * shape how a job runs (the lock, the heavy lane) are applied at binding, so a + * queue's protections do not depend on whether its module has loaded yet. A + * module that fails to load fails the job that asked for it, with the queue's + * name on the error, and the next job tries the import again. + */ +import type { Job, PgBoss } from "pg-boss"; + +import { heavyLane } from "@/lib/jobs/heavy-lane"; +import { lockedPass } from "@/lib/jobs/long-pass"; +import { + createAndSchedule, + createAndWork, + retireQueues, + type QueuePolicyTable, + type ScheduleEntry, +} from "@/lib/jobs/reminder/registrar-shared"; +import { workerLog } from "@/lib/jobs/reminder/shared"; + +import type { + BatchHandler, + BootDiscovery, + JobContext, + JobDefinition, + JobGroup, +} from "./define-job"; + +/** + * The handler a definition binds: its loaded module behind the lock and the + * lane it declares. + */ +export function bindableHandler( + definition: JobDefinition, + ctx: JobContext, +): BatchHandler> { + let loading: Promise>> | null = null; + const load = (): Promise>> => { + loading ??= definition.load(ctx).catch((err: unknown) => { + loading = null; + throw err; + }); + return loading; + }; + let handler: BatchHandler> = async (jobs) => + (await load())(jobs); + if (definition.lane) { + handler = heavyLane( + definition.queue, + handler, + definition.lane === true ? undefined : definition.lane, + ); + } + if (definition.lock) { + handler = lockedPass(definition.queue, definition.lock, handler); + } + return handler; +} + +/** The schedule tuples of a group, in declaration order. */ +export function schedulesOf(jobs: readonly JobDefinition[]): ScheduleEntry[] { + return jobs.flatMap((job): ScheduleEntry[] => + job.cron + ? [ + job.scheduleOptions + ? [job.queue, job.cron, { ...job.scheduleOptions }] + : [job.queue, job.cron], + ] + : [], + ); +} + +/** The policy table of a group. */ +export function policiesOf(jobs: readonly JobDefinition[]): QueuePolicyTable { + return Object.fromEntries( + jobs.flatMap((job) => (job.policy ? [[job.queue, job.policy]] : [])), + ); +} + +/** + * Register one group: create, schedule, retire, bind. Returns the queue names + * it created. + */ +export async function registerJobGroup( + boss: PgBoss, + group: JobGroup, +): Promise { + const queues = group.jobs.map((job) => job.queue); + await createAndSchedule( + boss, + queues, + schedulesOf(group.jobs), + policiesOf(group.jobs), + ); + if (group.retired) await retireQueues(boss, group.retired); + const ctx: JobContext = { boss }; + for (const job of group.jobs) { + await createAndWork(boss, job.queue, job.work, bindableHandler(job, ctx)); + } + if (group.afterBind) await group.afterBind(ctx); + return queues; +} + +/** + * Run one group's boot discovery. A pass that fails is logged and the next + * one runs: a discovery miss never fails the boot, and the next boot or cron + * tick offers the work again. + */ +export async function runBootDiscovery( + passes: readonly BootDiscovery[], +): Promise { + for (const pass of passes) { + try { + const result = await pass.run(); + if (!result) continue; + if (result.error) { + workerLog( + "error", + `[${pass.name}] boot discovery failed: ${result.error}`, + ); + } else { + const skipped = + result.skipped === undefined ? "" : ` skipped=${result.skipped}`; + workerLog( + "info", + `[${pass.name}] boot discovery: enqueued=${result.enqueued}${skipped}`, + ); + } + } catch (err) { + workerLog( + "error", + `[${pass.name}] boot discovery threw an unexpected error`, + err, + ); + } + } +} diff --git a/src/lib/jobs/registry/reminders.ts b/src/lib/jobs/registry/reminders.ts new file mode 100644 index 000000000..e66f90434 --- /dev/null +++ b/src/lib/jobs/registry/reminders.ts @@ -0,0 +1,70 @@ +/** + * The user-facing reminder ticks: the medication overdue check, the daily + * mood reminder, the cycle reminder, the checkup (measurement) reminder, and + * the eventful reminder-satisfy queue. + * + * The three daily reminders tick every 15 minutes and short-circuit unless + * the user's local time is the reminder's hour, so one UTC cron serves every + * zone; each keeps its own once-a-day anchor (a dispatch ledger, the + * `push_attempts` row, the reminder's `nextDueAt`). Every tick is + * single-flight so two ticks never race that anchor. + * + * Every queue here keeps pg-boss's default retries: a job-level failure means + * the pass itself could not run, a transient. The satisfy queue sets its own + * `retryLimit` at send time (`reminder-satisfy.ts`). + */ +import { + REMINDER_SATISFY_CONCURRENCY, + REMINDER_SATISFY_QUEUE, +} from "@/lib/jobs/reminder-satisfy"; + +import { defineJob, type JobDefinition, type JobGroup } from "./define-job"; + +const MEDICATION_REMINDER_CHECK_QUEUE = "medication-reminder-check"; +const MEDICATION_REMINDER_CHECK_CRON = "*/15 * * * *"; +const MOOD_REMINDER_QUEUE = "mood-reminder-check"; +const MOOD_REMINDER_CRON = "*/15 * * * *"; +const CYCLE_REMINDER_QUEUE = "cycle-reminder-check"; +const CYCLE_REMINDER_CRON = "*/15 * * * *"; +const MEASUREMENT_REMINDER_QUEUE = "measurement-reminder-check"; +const MEASUREMENT_REMINDER_CRON = "*/15 * * * *"; + +const checks = () => import("@/lib/jobs/reminder/mood-cycle-checks"); + +const jobs: readonly JobDefinition[] = [ + defineJob({ + queue: MEDICATION_REMINDER_CHECK_QUEUE, + cron: MEDICATION_REMINDER_CHECK_CRON, + work: { localConcurrency: 1 }, + load: async () => + (await import("@/lib/jobs/reminder/medication-reminder-check")) + .handleReminderCheck, + }), + defineJob({ + queue: MOOD_REMINDER_QUEUE, + cron: MOOD_REMINDER_CRON, + work: { localConcurrency: 1 }, + load: async () => (await checks()).handleMoodReminderCheck, + }), + defineJob({ + queue: CYCLE_REMINDER_QUEUE, + cron: CYCLE_REMINDER_CRON, + work: { localConcurrency: 1 }, + load: async () => (await checks()).handleCycleReminderCheck, + }), + defineJob({ + queue: MEASUREMENT_REMINDER_QUEUE, + cron: MEASUREMENT_REMINDER_CRON, + work: { localConcurrency: 1 }, + load: async () => (await checks()).handleMeasurementReminderCheck, + }), + // No cron: enqueued by the ingest paths when a measurement or lab lands; + // the 15-minute checkup tick is the safety net. + defineJob({ + queue: REMINDER_SATISFY_QUEUE, + work: { localConcurrency: REMINDER_SATISFY_CONCURRENCY }, + load: async () => (await checks()).handleReminderSatisfy, + }), +]; + +export const REMINDERS_GROUP: JobGroup = { name: "reminders", jobs }; diff --git a/src/lib/jobs/registry/rollup.ts b/src/lib/jobs/registry/rollup.ts new file mode 100644 index 000000000..cb885c0dc --- /dev/null +++ b/src/lib/jobs/registry/rollup.ts @@ -0,0 +1,281 @@ +/** + * The pre-aggregation and consolidation queues: the measurement and mood + * rollup folds with their boot backfills, the legacy step consolidation and + * its repair, the cumulative-record re-derivation, the daily-mean + * consolidation, the dense intra-day retention and hourly rebuild, the + * supplement nutrient recompute, and the nightly drain. + * + * The two recompute queues and the supplement recompute are `short`: the + * handler reads the bucket's live rows when it starts, so sends that pile up + * while an identical job is still queued may collapse, while a send issued + * after the job started reading must still queue the next one. The per-user + * backfills are `exclusive`: a second run is duplicated work and discovery + * re-offers whatever is outstanding. The drain is a keyless cron tick. + * + * Boot discovery is staggered: each backfill starts a multiple of + * `BOOT_BACKFILL_STAGGER_SECONDS` later, so a heavy account's full-history + * loads do not all land on the first poll. The dense passes defer themselves + * further, past the boot window. + */ +import type { Job } from "pg-boss"; + +import { + DENSE_INTRADAY_HOURLY_REBUILD_CONCURRENCY, + DENSE_INTRADAY_HOURLY_REBUILD_QUEUE, +} from "@/lib/jobs/dense-intraday-hourly-rebuild-queue"; +import { + DENSE_INTRADAY_RETENTION_CONCURRENCY, + DENSE_INTRADAY_RETENTION_QUEUE, +} from "@/lib/jobs/dense-intraday-retention-queue"; +import { + DRAIN_CUMULATIVE_CRON, + DRAIN_CUMULATIVE_QUEUE, +} from "@/lib/jobs/drain-cumulative-queue"; +import { + MEAN_CONSOLIDATION_CONCURRENCY, + MEAN_CONSOLIDATION_QUEUE, +} from "@/lib/jobs/mean-consolidation"; +import { cronIsTheRetry } from "@/lib/jobs/reminder/registrar-shared"; +import { BOOT_BACKFILL_STAGGER_SECONDS } from "@/lib/jobs/reminder/shared"; +import { + STEP_CONSOLIDATION_CONCURRENCY, + STEP_CONSOLIDATION_QUEUE, +} from "@/lib/jobs/step-consolidation"; +import { + STEP_CONSOLIDATION_REPAIR_CONCURRENCY, + STEP_CONSOLIDATION_REPAIR_QUEUE, +} from "@/lib/jobs/step-consolidation-repair"; +import { + SUPPLEMENT_NUTRIENT_RECOMPUTE_CONCURRENCY, + SUPPLEMENT_NUTRIENT_RECOMPUTE_QUEUE, +} from "@/lib/medications/ingredients/recompute-job"; +import { + CUMULATIVE_PR_REDERIVE_CONCURRENCY, + CUMULATIVE_PR_REDERIVE_QUEUE, +} from "@/lib/personal-records/cumulative-pr-rederivation"; +import { + ROLLUP_FULL_BACKFILL_CONCURRENCY, + ROLLUP_FULL_BACKFILL_QUEUE, + ROLLUP_RECOMPUTE_CONCURRENCY, + ROLLUP_RECOMPUTE_QUEUE, + type RollupFullBackfillPayload, +} from "@/lib/rollups/measurement-rollups"; +import { + MOOD_ROLLUP_FULL_BACKFILL_CONCURRENCY, + MOOD_ROLLUP_FULL_BACKFILL_QUEUE, + MOOD_ROLLUP_RECOMPUTE_CONCURRENCY, + MOOD_ROLLUP_RECOMPUTE_QUEUE, +} from "@/lib/rollups/mood-rollups"; + +import { + defineJob, + type BootDiscovery, + type JobDefinition, + type JobGroup, +} from "./define-job"; + +/** + * The boot backfill of the per-day medication-compliance rollup, whose table + * had no reader left and was dropped. Its queue is removed where it exists. + */ +const RETIRED_ROLLUP_QUEUES = ["medication-compliance-full-backfill"] as const; + +const handlers = () => import("@/lib/jobs/reminder/rollup-handlers"); + +const jobs: readonly JobDefinition[] = [ + defineJob({ + queue: ROLLUP_RECOMPUTE_QUEUE, + policy: { + policy: "short", + reason: + "Burst coalescing for the write-path fan-out. The handler re-reads the bucket at run time, so collapsing queued duplicates cannot strand a write; exclusive could.", + }, + work: { localConcurrency: ROLLUP_RECOMPUTE_CONCURRENCY }, + load: async () => (await handlers()).handleRollupRecompute, + }), + // The lock sits outside the lane: a retry pg-boss starts after the expiry + // while the first fold still runs finds the account's lock and settles, + // instead of waiting in the lane and folding the account again. + defineJob({ + queue: ROLLUP_FULL_BACKFILL_QUEUE, + policy: { + policy: "exclusive", + reason: + "Per-user full-history backfill; long-running. Boot discovery re-enqueues while coverage is still missing, so suppressing a duplicate of an ACTIVE pass is safe.", + }, + work: { localConcurrency: ROLLUP_FULL_BACKFILL_CONCURRENCY }, + lock: (job: Job) => job.data.userId, + lane: true, + load: async () => (await handlers()).handleRollupFullBackfill, + }), + defineJob({ + queue: MOOD_ROLLUP_RECOMPUTE_QUEUE, + policy: { + policy: "short", + reason: + "Same shape as the measurement recompute: re-reads the bucket at run time, so only queued duplicates may be collapsed.", + }, + work: { localConcurrency: MOOD_ROLLUP_RECOMPUTE_CONCURRENCY }, + load: async () => (await handlers()).handleMoodRollupRecompute, + }), + defineJob({ + queue: MOOD_ROLLUP_FULL_BACKFILL_QUEUE, + policy: { + policy: "exclusive", + reason: + "Per-user mood backfill; boot-discovery driven and self-converging on rollup coverage.", + }, + work: { localConcurrency: MOOD_ROLLUP_FULL_BACKFILL_CONCURRENCY }, + load: async () => (await handlers()).handleMoodRollupFullBackfill, + }), + defineJob({ + queue: STEP_CONSOLIDATION_QUEUE, + policy: { + policy: "exclusive", + reason: + "Per-user legacy step consolidation; discovery drops the user once no live granular rows remain.", + }, + work: { localConcurrency: STEP_CONSOLIDATION_CONCURRENCY }, + load: async () => (await handlers()).handleStepConsolidation, + }), + defineJob({ + queue: STEP_CONSOLIDATION_REPAIR_QUEUE, + policy: { + policy: "exclusive", + reason: + "Per-user one-shot repair; discovery drops the user once the affected provider rows are repaired.", + }, + work: { localConcurrency: STEP_CONSOLIDATION_REPAIR_CONCURRENCY }, + load: async () => (await handlers()).handleStepConsolidationRepair, + }), + defineJob({ + queue: CUMULATIVE_PR_REDERIVE_QUEUE, + policy: { + policy: "exclusive", + reason: + "Per-user one-shot re-derivation; discovery drops the user once the inflated records are rewritten.", + }, + work: { localConcurrency: CUMULATIVE_PR_REDERIVE_CONCURRENCY }, + load: async () => (await handlers()).handleCumulativePrRederive, + }), + defineJob({ + queue: MEAN_CONSOLIDATION_QUEUE, + policy: { + policy: "exclusive", + reason: + "Per-user daily-mean consolidation; discovery drops the user once the spot rows are folded.", + }, + work: { localConcurrency: MEAN_CONSOLIDATION_CONCURRENCY }, + load: async () => (await handlers()).handleMeanConsolidation, + }), + defineJob({ + queue: DENSE_INTRADAY_RETENTION_QUEUE, + policy: { + policy: "exclusive", + reason: + "Per-user dense-tier drain; the multi-hour pass on a heavy account is exactly the case where a restart used to append a duplicate full-history job.", + }, + work: { localConcurrency: DENSE_INTRADAY_RETENTION_CONCURRENCY }, + load: async () => (await handlers()).handleDenseIntradayRetention, + }), + defineJob({ + queue: DENSE_INTRADAY_HOURLY_REBUILD_QUEUE, + policy: { + policy: "exclusive", + reason: + "Per-user one-shot hourly rebuild; discovery drops the day once its retired daily row is gone.", + }, + work: { localConcurrency: DENSE_INTRADAY_HOURLY_REBUILD_CONCURRENCY }, + load: async () => (await handlers()).handleDenseIntradayHourlyRebuild, + }), + // One sequential walk, so it never crowds the dashboard request pool. + defineJob({ + queue: DRAIN_CUMULATIVE_QUEUE, + cron: DRAIN_CUMULATIVE_CRON, + scheduleOptions: cronIsTheRetry, + work: { localConcurrency: 1 }, + load: async ({ boss }) => (await handlers()).drainCumulativeHandler(boss), + }), + defineJob({ + queue: SUPPLEMENT_NUTRIENT_RECOMPUTE_QUEUE, + policy: { + policy: "short", + reason: + "Per-account recompute of supplement nutrient rows; the pass reads the live doses and composition when it starts, so a queued duplicate may be collapsed, while a send during a running pass must still queue the next one.", + }, + work: { localConcurrency: SUPPLEMENT_NUTRIENT_RECOMPUTE_CONCURRENCY }, + load: async () => (await handlers()).handleSupplementNutrientRecompute, + }), +]; + +const discovery: readonly BootDiscovery[] = [ + { + name: "rollup-full-backfill", + run: async () => + ( + await import("@/lib/rollups/measurement-rollups") + ).enqueueBootTimeRollupBackfill(BOOT_BACKFILL_STAGGER_SECONDS * 0), + }, + { + name: "step-consolidation", + run: async () => + ( + await import("@/lib/jobs/step-consolidation") + ).enqueueBootTimeStepConsolidation(BOOT_BACKFILL_STAGGER_SECONDS * 1), + }, + { + name: "mean-consolidation", + run: async () => + ( + await import("@/lib/jobs/mean-consolidation") + ).enqueueBootTimeMeanConsolidation(BOOT_BACKFILL_STAGGER_SECONDS * 2), + }, + // Defers itself past the boot window (the pool-exhaustion fix). + { + name: "dense-intraday-retention", + run: async () => + ( + await import("@/lib/jobs/dense-intraday-retention") + ).enqueueBootTimeDenseIntradayRetention(), + }, + // Defers past the boot window and the retention drain's own stage. + { + name: "dense-intraday-hourly-rebuild", + run: async () => + ( + await import("@/lib/jobs/dense-intraday-hourly-rebuild") + ).enqueueBootTimeDenseIntradayHourlyRebuild(), + }, + { + name: "mood-rollup-full-backfill", + run: async () => + ( + await import("@/lib/rollups/mood-rollups") + ).enqueueBootTimeMoodRollupBackfill(BOOT_BACKFILL_STAGGER_SECONDS * 3), + }, + { + name: "step-consolidation-repair", + run: async () => + ( + await import("@/lib/jobs/step-consolidation-repair") + ).enqueueBootTimeStepConsolidationRepair( + BOOT_BACKFILL_STAGGER_SECONDS * 5, + ), + }, + { + name: "cumulative-pr-rederive", + run: async () => + ( + await import("@/lib/personal-records/cumulative-pr-rederivation") + ).enqueueBootTimeCumulativePrRederivation( + BOOT_BACKFILL_STAGGER_SECONDS * 6, + ), + }, +]; + +export const ROLLUP_GROUP: JobGroup = { + name: "rollup", + jobs, + retired: RETIRED_ROLLUP_QUEUES, + discovery, +}; diff --git a/src/lib/jobs/registry/status.ts b/src/lib/jobs/registry/status.ts new file mode 100644 index 000000000..104007bcd --- /dev/null +++ b/src/lib/jobs/registry/status.ts @@ -0,0 +1,260 @@ +/** + * The status, insight and computed-score queues: the nightly status batch, + * the comprehensive-insight pre-generation, the on-demand per-metric status, + * the morning digest refresh, the data-arrival spine with its reaction line + * and workout insight, the nightly scores, the period-narrative warm, the + * Coach memory refresh, nudge, reminder sweep and plan review, the low-stock + * pass and the daily-briefing fallback. + * + * Most queues here are keyless cron ticks, so they carry no policy. The + * LLM-bound warm queues (pre-generate, per-metric status, period narrative, + * Coach memory) pass `singletonSeconds` on every send, which pg-boss + * constrains whatever the policy, so they are left alone. The three that carry + * one key on the user's local date, which no wall-clock window can express. + */ +import type { Job } from "pg-boss"; + +import { COACH_MEMORY_REFRESH_QUEUE } from "@/lib/ai/coach/coach-memory-shared"; +import { DATA_ARRIVAL_QUEUE } from "@/lib/arrivals/emit-shared"; +import { REACTION_LINE_QUEUE } from "@/lib/arrivals/reaction-line-shared"; +import { + COACH_NUDGE_CRON, + COACH_NUDGE_QUEUE, +} from "@/lib/jobs/coach-nudge-queue"; +import { + COACH_PLAN_REVIEW_CRON, + COACH_PLAN_REVIEW_QUEUE, +} from "@/lib/jobs/coach-plan-review"; +import { + COACH_REMINDER_SWEEP_CRON, + COACH_REMINDER_SWEEP_QUEUE, +} from "@/lib/jobs/coach-reminder-sweep-queue"; +import { + DAILY_BRIEFING_CRON, + DAILY_BRIEFING_QUEUE, +} from "@/lib/jobs/daily-briefing-queue"; +import { + INSIGHT_PREGENERATE_CRON, + INSIGHT_PREGENERATE_EXPIRE_SECONDS, + INSIGHT_PREGENERATE_QUEUE, + type InsightPregeneratePayload, +} from "@/lib/jobs/insight-pregenerate-shared"; +import { + INSIGHT_STATUS_GENERATE_CONCURRENCY, + INSIGHT_STATUS_GENERATE_QUEUE, +} from "@/lib/jobs/insight-status-generate-queue"; +import { WHOLE_PASS } from "@/lib/jobs/long-pass"; +import { + MEDICATION_LOW_STOCK_CRON, + MEDICATION_LOW_STOCK_QUEUE, +} from "@/lib/jobs/medication-low-stock-queue"; +import { MORNING_DIGEST_REFRESH_QUEUE } from "@/lib/jobs/morning-digest-refresh-shared"; +import { + PERIOD_NARRATIVE_CRON, + PERIOD_NARRATIVE_EXPIRE_SECONDS, + PERIOD_NARRATIVE_QUEUE, +} from "@/lib/jobs/period-narrative-queue"; +import { + RECOVERY_SCORE_CRON, + RECOVERY_SCORE_QUEUE, +} from "@/lib/jobs/recovery-score-queue"; +import { + insightRetryOptions, + nightlyInsightPassOptions, +} from "@/lib/jobs/reminder/registrar-shared"; +import { + STRAIN_SCORE_CRON, + STRAIN_SCORE_QUEUE, +} from "@/lib/jobs/strain-score-queue"; +import { + STRESS_SCORE_CRON, + STRESS_SCORE_QUEUE, +} from "@/lib/jobs/stress-score-queue"; +import { + WORKOUT_INSIGHT_GENERATE_CONCURRENCY, + WORKOUT_INSIGHT_GENERATE_QUEUE, +} from "@/lib/jobs/workout-insight-generate-shared"; + +import { defineJob, type JobDefinition, type JobGroup } from "./define-job"; + +const GENERAL_STATUS_QUEUE = "insights-general-status"; +const GENERAL_STATUS_CRON = "0 2 * * *"; + +/** + * The six per-metric status crons that followed the 02:00 batch. The batch + * builds every card, so they only re-read cards it had just written; their + * schedules and queues are removed where they still exist. + */ +const RETIRED_STATUS_QUEUES = [ + "insights-blood-pressure-status", + "insights-weight-status", + "insights-pulse-status", + "insights-bmi-status", + "insights-mood-status", + "insights-medication-compliance-status", +] as const; + +const insights = () => import("@/lib/jobs/reminder/insights-handlers"); +const handlers = () => import("@/lib/jobs/reminder/status-handlers"); + +/** A pre-generation job that warms one account on a visit. */ +const isForcedWarm = (job: Job) => + Boolean(job.data?.force && job.data.userId); + +const jobs: readonly JobDefinition[] = [ + // Every per-metric status card, one provider call per account. The options + // ride on the schedule because the cron is the only sender. + defineJob({ + queue: GENERAL_STATUS_QUEUE, + cron: GENERAL_STATUS_CRON, + scheduleOptions: nightlyInsightPassOptions, + work: { localConcurrency: 1 }, + lock: () => WHOLE_PASS, + lane: true, + load: async () => (await insights()).handleGeneralStatusGenerate, + }), + // Two slots: the cohort walk and a visitor's forced warm. The walk is one + // pass at a time and runs in the lane; a forced warm is one per account and + // never waits behind the walk. + defineJob({ + queue: INSIGHT_PREGENERATE_QUEUE, + cron: INSIGHT_PREGENERATE_CRON, + scheduleOptions: { + ...insightRetryOptions, + expireInSeconds: INSIGHT_PREGENERATE_EXPIRE_SECONDS, + }, + work: { localConcurrency: 2 }, + lock: (job: Job) => + isForcedWarm(job) ? `user:${job.data.userId}` : WHOLE_PASS, + lane: (job: Job) => !isForcedWarm(job), + load: async () => (await insights()).handleInsightPregenerateJob, + }), + // Enqueued by the read-only status route on a cold card; low concurrency so + // a first visit cannot fan out unbounded provider calls. + defineJob({ + queue: INSIGHT_STATUS_GENERATE_QUEUE, + work: { localConcurrency: INSIGHT_STATUS_GENERATE_CONCURRENCY }, + load: async () => (await insights()).handleInsightStatusGenerate, + }), + // Seven sleep-write seams enqueue the same user + local-date key. + defineJob({ + queue: MORNING_DIGEST_REFRESH_QUEUE, + policy: { + policy: "exclusive", + reason: + "Seven sleep-write seams enqueue the same user+localDate key; exclusive makes the at-most-once-per-morning contract real. A wall-clock singletonSeconds window cannot express a local date.", + }, + work: { localConcurrency: 1 }, + load: async () => (await handlers()).handleMorningDigestRefresh, + }), + // The arrival spine claims a durable unique row, so a second slot would only + // race the claim; it makes no provider call. + defineJob({ + queue: DATA_ARRIVAL_QUEUE, + policy: { + policy: "exclusive", + reason: + "Day-scoped arrival keys carry the user's local date; under standard they coalesce nothing and a chatty ingest queues a job per batch. The worker claims a durable unique row, so a concurrent second run is pure duplicated work.", + }, + work: { localConcurrency: 1 }, + load: async () => + (await import("@/lib/jobs/data-arrival")).handleDataArrival, + }), + // The spine's one provider call, behind the durable `generatedAt` claim. + defineJob({ + queue: REACTION_LINE_QUEUE, + policy: { + policy: "exclusive", + reason: + "Day-scoped per-kind keys carry the user's local date; under standard they coalesce nothing and a retried spine job could stack a second provider call behind the first.", + }, + work: { localConcurrency: 1 }, + load: async () => + (await import("@/lib/jobs/reaction-line")).handleReactionLine, + }), + defineJob({ + queue: WORKOUT_INSIGHT_GENERATE_QUEUE, + work: { localConcurrency: WORKOUT_INSIGHT_GENERATE_CONCURRENCY }, + load: async () => + (await import("@/lib/jobs/workout-insight-generate")) + .handleWorkoutInsightGenerate, + }), + // The nightly scores, in order after the consolidation and drain they read: + // recovery 04:45, stress 04:50, strain 04:55. + defineJob({ + queue: RECOVERY_SCORE_QUEUE, + cron: RECOVERY_SCORE_CRON, + work: { localConcurrency: 1 }, + load: async () => (await handlers()).handleRecoveryScore, + }), + defineJob({ + queue: STRESS_SCORE_QUEUE, + cron: STRESS_SCORE_CRON, + work: { localConcurrency: 1 }, + load: async () => (await handlers()).handleStressScore, + }), + defineJob({ + queue: STRAIN_SCORE_QUEUE, + cron: STRAIN_SCORE_CRON, + work: { localConcurrency: 1 }, + load: async () => (await handlers()).handleStrainScore, + }), + // The nightly fan-out runs on a week or month boundary, in the lane and one + // pass at a time; a single account's warm for the GET runs beside it. + defineJob({ + queue: PERIOD_NARRATIVE_QUEUE, + cron: PERIOD_NARRATIVE_CRON, + scheduleOptions: { + ...insightRetryOptions, + expireInSeconds: PERIOD_NARRATIVE_EXPIRE_SECONDS, + }, + work: { localConcurrency: 1 }, + lock: (job: Job<{ userId?: string }>) => + job.data?.userId ? `user:${job.data.userId}` : WHOLE_PASS, + lane: (job: Job<{ userId?: string }>) => !job.data?.userId, + load: async () => (await handlers()).handlePeriodNarrative, + }), + // One slot, so a burst of long conversations cannot fan out provider calls. + defineJob({ + queue: COACH_MEMORY_REFRESH_QUEUE, + work: { localConcurrency: 1 }, + load: async () => (await handlers()).handleCoachMemoryRefresh, + }), + defineJob({ + queue: COACH_NUDGE_QUEUE, + cron: COACH_NUDGE_CRON, + work: { localConcurrency: 1 }, + load: async () => (await handlers()).handleCoachNudge, + }), + defineJob({ + queue: COACH_REMINDER_SWEEP_QUEUE, + cron: COACH_REMINDER_SWEEP_CRON, + work: { localConcurrency: 1 }, + load: async () => (await handlers()).handleCoachReminderSweep, + }), + defineJob({ + queue: COACH_PLAN_REVIEW_QUEUE, + cron: COACH_PLAN_REVIEW_CRON, + work: { localConcurrency: 1 }, + load: async () => (await handlers()).handleCoachPlanReview, + }), + defineJob({ + queue: MEDICATION_LOW_STOCK_QUEUE, + cron: MEDICATION_LOW_STOCK_CRON, + work: { localConcurrency: 1 }, + load: async () => (await handlers()).handleMedicationLowStock, + }), + // Every 15 minutes; the per-user local-hour gate picks each user's slot. + defineJob({ + queue: DAILY_BRIEFING_QUEUE, + cron: DAILY_BRIEFING_CRON, + work: { localConcurrency: 1 }, + load: async () => (await handlers()).handleDailyBriefing, + }), +]; + +export const STATUS_GROUP: JobGroup = { + name: "status", + jobs, + retired: RETIRED_STATUS_QUEUES, +}; diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index e486a92db..c882ca002 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -1,15 +1,12 @@ /** - * pg-boss based reminder worker — boot composition. + * pg-boss worker boot. * - * v1.18.1 — this file was a 2143-LOC monolith that declared every pg-boss - * queue name, listed them in one `allQueues` array, scheduled every cron, and - * bound every `boss.work` handler inline. It is now a thin registration table: - * `startReminderWorker()` boots pg-boss, runs the boot one-shots, then calls - * the domain registrars under `src/lib/jobs/reminder/`. Each registrar owns the - * four facts the v1.4.37 dead-queue guards pin — the queue-name constant, its - * `allQueues` membership, its `[QUEUE, CRON]` schedule tuple, and its - * `createAndWork(boss, QUEUE, …, handler)` binding — so a queue can never be - * declared without being provisioned, scheduled, and drained. + * `startReminderWorker()` starts pg-boss, registers every queue from the + * declarative registry (`./registry/`), runs the boot one-shots and the boot + * discovery. The registry declares each queue with its schedule, policy, + * concurrency and handler loader; a queue cannot be declared without being + * created, scheduled and drained. Handler modules load on their queue's first + * job, not at boot. * * Usage: Run as a standalone process or call startReminderWorker() from a * custom server setup. In dev, use: npx tsx src/lib/jobs/reminder-worker.ts @@ -20,23 +17,15 @@ import { reportJobsCutOffAtBoot } from "@/lib/jobs/job-observer"; import { beginBootStep, reportBootSteps } from "@/lib/boot/boot-steps"; import { assertSubsystemEnabled } from "@/lib/process-type"; import { DATABASE_URL, workerLog } from "./reminder/shared"; -import type { PgBoss } from "pg-boss"; /** - * Give the event loop a turn. Each registrar's module graph is evaluated in - * one synchronous stretch when it is first imported; loading the five one - * after another, with a turn in between, keeps the longest stall to the - * largest graph instead of the sum of all five, so requests and the health - * probe are answered in between. + * Give the event loop a turn around a module load, so requests and the + * health probe are answered between boot steps. */ const yieldToLoop = () => new Promise((resolve) => setImmediate(resolve)); -/** - * Import one registrar module as its own boot step. The registrars and the - * handler graphs behind them are loaded here, after `boss.start()`, rather - * than when this module is imported. - */ -async function loadRegistrar( +/** Import one boot module as its own boot step. */ +async function loadBootModule( name: string, load: () => Promise, ): Promise { @@ -74,65 +63,25 @@ export async function startReminderWorker() { markWorkerStarted(); // Graceful shutdown: drain in-flight jobs on SIGTERM/SIGINT. - const lifecycle = await loadRegistrar( + const lifecycle = await loadBootModule( "worker_lifecycle", () => import("./reminder/worker-lifecycle"), ); lifecycle.registerShutdownHandlers(boss); - // Register every domain's queues (createQueue + schedule + boss.work). Each - // registrar returns the queue names it provisioned; the aggregate is the - // boot-level mirror of the per-registrar `allQueues` arrays the dead-queue - // guards pin. Order preserves the monolith's createQueue / schedule / work - // sequence (integration → status → rollup → reminders → maintenance). - const integration = await loadRegistrar( - "integration_sync", - () => import("./reminder/register-integration-sync"), + // Register every queue (createQueue + schedule + boss.work) from the + // declarative registry, one group per boot step. The registry holds + // declarations only; each handler module loads on its queue's first job. + const registry = await loadBootModule( + "registry", + () => import("./registry/index"), ); - const registeredQueues = await registerInStep( - "integration_sync", - boss, - integration.registerIntegrationSyncQueues, - ); - const status = await loadRegistrar( - "status", - () => import("./reminder/register-status"), - ); - registeredQueues.push( - ...(await registerInStep("status", boss, status.registerStatusQueues)), - ); - const rollup = await loadRegistrar( - "rollup", - () => import("./reminder/register-rollup"), - ); - registeredQueues.push( - ...(await registerInStep("rollup", boss, rollup.registerRollupQueues)), - ); - const reminders = await loadRegistrar( - "reminders", - () => import("./reminder/register-reminders"), - ); - registeredQueues.push( - ...(await registerInStep( - "reminders", - boss, - reminders.registerReminderQueues, - )), - ); - const maintenance = await loadRegistrar( - "maintenance", - () => import("./reminder/register-maintenance"), - ); - registeredQueues.push( - ...(await registerInStep( - "maintenance", - boss, - maintenance.registerMaintenanceQueues, - )), + const registeredQueues = await registry.registerAllJobGroups(boss, (group) => + beginBootStep(`register_${group.name}`), ); workerLog( "info", - `[reminder-worker] registered ${registeredQueues.length} queues across 5 domain registrars`, + `[reminder-worker] registered ${registeredQueues.length} queues across ${registry.JOB_GROUPS.length} groups`, ); // v1.4.34 — reconcile any `ImportJob` rows that were mid-parse when the @@ -146,26 +95,14 @@ export async function startReminderWorker() { // Fire-and-forget boot discovery for every self-converging backfill / // consolidation pass. Each is idempotent across reboots and never fails - // worker boot on a miss (errors come back through the helper's result). + // worker boot on a miss. beginBootStep("boot_discovery"); - await rollup.enqueueRollupBootDiscovery(); - await integration.enqueueIntegrationSyncBootDiscovery(); - await maintenance.enqueueMaintenanceBootDiscovery(); + await registry.runAllBootDiscovery(); await reportBootSteps({ boot_queue_count: registeredQueues.length }); return boss; } -/** Run one registrar as its own boot step. */ -async function registerInStep( - name: string, - boss: PgBoss, - register: (boss: PgBoss) => Promise, -): Promise { - beginBootStep(`register_${name}`); - return [...(await register(boss))]; -} - // Run standalone if ( process.argv[1]?.endsWith("reminder-worker.ts") || diff --git a/src/lib/jobs/reminder/climb-backfill-at-boot.ts b/src/lib/jobs/reminder/climb-backfill-at-boot.ts new file mode 100644 index 000000000..344b9c017 --- /dev/null +++ b/src/lib/jobs/reminder/climb-backfill-at-boot.ts @@ -0,0 +1,34 @@ +/** + * The one-time climb backfill for workouts stored with a track but no + * elevation (`workouts/climb-backfill.ts`), started at boot and left to run + * detached: it reads one track at a time with a pause between batches and + * resumes from its cursor on the next boot, so it never holds the boot or the + * heap. A finished pass returns at once. + */ +import { trackBackgroundTask } from "@/lib/logging/background-tasks"; + +import { workerLog } from "./shared"; + +export async function startClimbBackfill(): Promise { + trackBackgroundTask( + (async () => { + const [{ prisma }, { runWorkoutClimbBackfill }] = await Promise.all([ + import("@/lib/db"), + import("@/lib/workouts/climb-backfill"), + ]); + const result = await runWorkoutClimbBackfill({ prisma }); + if (result.scanned > 0) { + workerLog( + "info", + `[workout-climb-backfill] scanned=${result.scanned} filled=${result.filled} done=${result.done}`, + ); + } + })().catch((err) => { + workerLog( + "error", + "[workout-climb-backfill] failed; the next boot resumes from the cursor", + err, + ); + }), + ); +} diff --git a/src/lib/jobs/reminder/document-handlers.ts b/src/lib/jobs/reminder/document-handlers.ts new file mode 100644 index 000000000..fa7c2d55c --- /dev/null +++ b/src/lib/jobs/reminder/document-handlers.ts @@ -0,0 +1,233 @@ +/** + * Handlers of the document vault queues that are not a module's own export: + * the content-index backfill, the per-document index, thumbnail and summary, + * the auto-read catch-up and the thumbnail backfill. A payload without its ids + * is counted as skipped rather than failed. The registry binds them + * (`registry/maintenance.ts`). + */ +import type { Job } from "pg-boss"; + +import { + runContentIndexBackfillForUser, + type ContentIndexBackfillPayload, +} from "@/lib/jobs/document-content-index-backfill"; +import { + runDocumentIndex, + type DocumentIndexPayload, +} from "@/lib/jobs/document-index"; +import { + runDocumentSummaryJob, + type DocumentSummaryPayload, +} from "@/lib/jobs/document-summary"; +import { + runSummaryCatchUpForUser, + type SummaryCatchUpPayload, +} from "@/lib/jobs/document-summary-catchup"; +import { + runDocumentThumbnail, + type DocumentThumbnailPayload, +} from "@/lib/jobs/document-thumbnail"; +import { + runThumbnailBackfillForUser, + type ThumbnailBackfillPayload, +} from "@/lib/jobs/document-thumbnail-backfill"; +import { jobBudget } from "@/lib/jobs/job-budget"; +import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome"; +import { recordError } from "@/lib/jobs/worker-status"; + +import { workerLog } from "./shared"; + +/** + * The on-demand "index all documents" run for one account: consent and + * budget gated, bounded and resumable. + */ +export async function handleContentIndexBackfill( + jobs: Job[], +): Promise { + let users = 0; + let stoppedEarly = false; + const shouldStop = jobBudget(jobs); + let skipped = 0; + let documentsIndexed = 0; + let documentsRetokenised = 0; + let documentsSkipped = 0; + let documentsFailed = 0; + for (const job of jobs) { + const { userId } = job.data; + if (!userId) { + skipped++; + continue; + } + try { + const { + indexed, + retokenised, + skipped: docsSkipped, + failed: docsFailed, + reason, + stoppedEarly: stopped, + } = await runContentIndexBackfillForUser(userId, shouldStop); + users++; + stoppedEarly ||= stopped === true; + documentsIndexed += indexed; + documentsRetokenised += retokenised; + documentsSkipped += docsSkipped; + documentsFailed += docsFailed; + workerLog( + "info", + `[document-content-index-backfill] user=${userId} indexed=${indexed} retokenised=${retokenised} skipped=${docsSkipped} failed=${docsFailed} reason=${reason}`, + ); + } catch (err) { + recordError(); + workerLog( + "error", + `[document-content-index-backfill] user=${userId} failed`, + err, + ); + throw err; + } + } + return jobDone({ + jobs: jobs.length, + users, + skipped, + documents_indexed: documentsIndexed, + documents_retokenised: documentsRetokenised, + documents_skipped: documentsSkipped, + documents_failed: documentsFailed, + stopped_early: stoppedEarly, + }); +} + +/** One stored document's job, by the step that runs it. */ +function perDocument

( + name: string, + run: (payload: P) => Promise, +): (jobs: Job

[]) => Promise { + return async (jobs) => { + let documents = 0; + let skipped = 0; + for (const job of jobs) { + const { userId, documentId } = job.data; + if (!userId || !documentId) { + skipped++; + continue; + } + try { + await run(job.data); + documents++; + } catch (err) { + recordError(); + workerLog( + "error", + `[${name}] user=${userId} document=${documentId} failed`, + err, + ); + throw err; + } + } + return jobDone({ jobs: jobs.length, documents, skipped }); + }; +} + +/** + * Content indexing on upload: the vision provider when configured and + * consented, the local text layer otherwise. + */ +export const handleDocumentIndex = perDocument( + "document-index", + runDocumentIndex, +); + +/** The preview thumbnail on upload: local decode and downscale, no egress. */ +export const handleDocumentThumbnail = perDocument( + "document-thumbnail", + runDocumentThumbnail, +); + +/** + * The plain-language summary on upload, only with the auto-read opt-in on + * (consent and budget gated), stored encrypted. + */ +export const handleDocumentSummary = perDocument( + "document-summary", + runDocumentSummaryJob, +); + +/** + * After a genuine off-to-on flip of the auto-read opt-in: one summary job per + * stored document the upload-time enqueue skipped. Every gate still runs + * inside the summary job. + */ +export async function handleDocumentSummaryCatchUp( + jobs: Job[], +): Promise { + let users = 0; + let skipped = 0; + let summariesEnqueued = 0; + for (const job of jobs) { + const { userId } = job.data; + if (!userId) { + skipped++; + continue; + } + try { + const { enqueued } = await runSummaryCatchUpForUser(userId); + users++; + summariesEnqueued += enqueued; + } catch (err) { + recordError(); + workerLog( + "error", + `[document-summary-catchup] user=${userId} failed`, + err, + ); + throw err; + } + } + return jobDone({ + jobs: jobs.length, + users, + skipped, + summaries_enqueued: summariesEnqueued, + }); +} + +/** One thumbnail job per not-yet-previewed document of the account. */ +export async function handleDocumentThumbnailBackfill( + jobs: Job[], +): Promise { + let users = 0; + let skipped = 0; + let thumbnailsEnqueued = 0; + for (const job of jobs) { + const { userId } = job.data; + if (!userId) { + skipped++; + continue; + } + try { + const { enqueued } = await runThumbnailBackfillForUser(userId); + users++; + thumbnailsEnqueued += enqueued; + workerLog( + "info", + `[document-thumbnail-backfill] user=${userId} enqueued=${enqueued}`, + ); + } catch (err) { + recordError(); + workerLog( + "error", + `[document-thumbnail-backfill] user=${userId} failed`, + err, + ); + throw err; + } + } + return jobDone({ + jobs: jobs.length, + users, + skipped, + thumbnails_enqueued: thumbnailsEnqueued, + }); +} diff --git a/src/lib/jobs/reminder/encryption-backfill-handlers.ts b/src/lib/jobs/reminder/encryption-backfill-handlers.ts new file mode 100644 index 000000000..33a8bde60 --- /dev/null +++ b/src/lib/jobs/reminder/encryption-backfill-handlers.ts @@ -0,0 +1,214 @@ +/** + * Handlers of the three converging encryption backfills: the mood and + * measurement notes, the medication notes, and the remaining free text (Coach + * titles, custom-metric notes, practitioner contacts, workout tracks). A job + * with a `userId` migrates that account; the daily tick (no `userId`) re-runs + * the boot discovery and fans out one job per account still holding a + * readable value. The registry binds them (`registry/maintenance.ts`). + */ +import type { Job } from "pg-boss"; + +import { + enqueueBootTimeFreeTextEncryptionBackfill, + runFreeTextEncryptionBackfillForUser, + type FreeTextEncryptionBackfillPayload, +} from "@/lib/jobs/free-text-encryption-backfill"; +import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome"; +import { + enqueueBootTimeMedNotesEncryptionBackfill, + runMedNotesEncryptionBackfillForUser, + type MedNotesEncryptionBackfillPayload, +} from "@/lib/jobs/med-notes-encryption-backfill"; +import { + enqueueBootTimeNoteEncryptionBackfill, + runNoteEncryptionBackfillForUser, + type NoteEncryptionBackfillPayload, +} from "@/lib/jobs/note-encryption-backfill"; +import { recordError } from "@/lib/jobs/worker-status"; + +import { workerLog } from "./shared"; + +export async function handleNoteEncryptionBackfill( + jobs: Job>[], +): Promise { + let users = 0; + let discoveryEnqueued = 0; + let discoverySkipped = 0; + // The discovery helper returns its error instead of throwing, so a failed + // fan-out would otherwise leave no trace on the job at all. + let discoveryFailed = 0; + let measurements = 0; + let moodEntries = 0; + for (const job of jobs) { + const { userId } = job.data; + if (!userId) { + const result = await enqueueBootTimeNoteEncryptionBackfill(); + discoveryEnqueued += result.enqueued; + discoverySkipped += result.skipped; + if (result.error) discoveryFailed++; + workerLog( + "info", + `[note-encryption-backfill] daily discovery enqueued=${result.enqueued} skipped=${result.skipped}${result.error ? ` error=${result.error}` : ""}`, + ); + continue; + } + try { + const { measurementsMigrated, moodEntriesMigrated } = + await runNoteEncryptionBackfillForUser(userId); + users++; + measurements += measurementsMigrated; + moodEntries += moodEntriesMigrated; + workerLog( + "info", + `[note-encryption-backfill] user=${userId} measurements=${measurementsMigrated} moodEntries=${moodEntriesMigrated}`, + ); + } catch (err) { + recordError(); + workerLog( + "error", + `[note-encryption-backfill] user=${userId} failed`, + err, + ); + throw err; + } + } + return jobDone({ + jobs: jobs.length, + users, + discovery_enqueued: discoveryEnqueued, + discovery_skipped: discoverySkipped, + discovery_failed: discoveryFailed, + measurements_migrated: measurements, + mood_entries_migrated: moodEntries, + }); +} + +export async function handleMedNotesEncryptionBackfill( + jobs: Job>[], +): Promise { + let users = 0; + let discoveryEnqueued = 0; + let discoverySkipped = 0; + let discoveryFailed = 0; + let sideEffects = 0; + let doseChanges = 0; + let inventoryItems = 0; + for (const job of jobs) { + const { userId } = job.data; + if (!userId) { + const result = await enqueueBootTimeMedNotesEncryptionBackfill(); + discoveryEnqueued += result.enqueued; + discoverySkipped += result.skipped; + if (result.error) discoveryFailed++; + workerLog( + "info", + `[med-notes-encryption-backfill] daily discovery enqueued=${result.enqueued} skipped=${result.skipped}${result.error ? ` error=${result.error}` : ""}`, + ); + continue; + } + try { + const { + sideEffectsMigrated, + doseChangesMigrated, + inventoryItemsMigrated, + } = await runMedNotesEncryptionBackfillForUser(userId); + users++; + sideEffects += sideEffectsMigrated; + doseChanges += doseChangesMigrated; + inventoryItems += inventoryItemsMigrated; + workerLog( + "info", + `[med-notes-encryption-backfill] user=${userId} sideEffects=${sideEffectsMigrated} doseChanges=${doseChangesMigrated} inventoryItems=${inventoryItemsMigrated}`, + ); + } catch (err) { + recordError(); + workerLog( + "error", + `[med-notes-encryption-backfill] user=${userId} failed`, + err, + ); + throw err; + } + } + return jobDone({ + jobs: jobs.length, + users, + discovery_enqueued: discoveryEnqueued, + discovery_skipped: discoverySkipped, + discovery_failed: discoveryFailed, + side_effects_migrated: sideEffects, + dose_changes_migrated: doseChanges, + inventory_items_migrated: inventoryItems, + }); +} + +export async function handleFreeTextEncryptionBackfill( + jobs: Job>[], +): Promise { + let users = 0; + let discoveryEnqueued = 0; + let discoverySkipped = 0; + let discoveryFailed = 0; + let titles = 0; + let notes = 0; + let contacts = 0; + let routes = 0; + let appointmentAddresses = 0; + let contactAudits = 0; + for (const job of jobs) { + const { userId } = job.data; + if (!userId) { + const result = await enqueueBootTimeFreeTextEncryptionBackfill(); + discoveryEnqueued += result.enqueued; + discoverySkipped += result.skipped; + if (result.error) discoveryFailed++; + workerLog( + "info", + `[free-text-encryption-backfill] daily discovery enqueued=${result.enqueued} skipped=${result.skipped}${result.error ? ` error=${result.error}` : ""}`, + ); + continue; + } + try { + const { + conversationTitlesMigrated, + metricNotesMigrated, + practitionerContactsMigrated, + routeGeometriesMigrated, + appointmentAddressesCleared, + contactAuditRowsScrubbed, + } = await runFreeTextEncryptionBackfillForUser(userId); + users++; + titles += conversationTitlesMigrated; + notes += metricNotesMigrated; + contacts += practitionerContactsMigrated; + routes += routeGeometriesMigrated; + appointmentAddresses += appointmentAddressesCleared; + contactAudits += contactAuditRowsScrubbed; + workerLog( + "info", + `[free-text-encryption-backfill] user=${userId} titles=${conversationTitlesMigrated} notes=${metricNotesMigrated} contacts=${practitionerContactsMigrated} routes=${routeGeometriesMigrated} appointment_addresses=${appointmentAddressesCleared} contact_audits=${contactAuditRowsScrubbed}`, + ); + } catch (err) { + recordError(); + workerLog( + "error", + `[free-text-encryption-backfill] user=${userId} failed`, + err, + ); + throw err; + } + } + return jobDone({ + jobs: jobs.length, + users, + discovery_enqueued: discoveryEnqueued, + discovery_skipped: discoverySkipped, + discovery_failed: discoveryFailed, + conversation_titles_migrated: titles, + metric_notes_migrated: notes, + practitioner_contacts_migrated: contacts, + route_geometries_migrated: routes, + appointment_addresses_cleared: appointmentAddresses, + contact_audit_rows_scrubbed: contactAudits, + }); +} diff --git a/src/lib/jobs/reminder/maintenance-handlers.ts b/src/lib/jobs/reminder/maintenance-handlers.ts new file mode 100644 index 000000000..1f1facfd7 --- /dev/null +++ b/src/lib/jobs/reminder/maintenance-handlers.ts @@ -0,0 +1,186 @@ +/** + * Handlers of the maintenance queues that are not a module's own export: the + * Apple Health and medication-intake imports, the duplicate dose-slot dedup, + * and the boss-bound environment and Dawarich passes. The registry binds them + * (`registry/maintenance.ts`) and loads this module on the first job. + */ +import type { Job, JobWithMetadata, PgBoss } from "pg-boss"; + +import { + handleAppleHealthImport, + migrateLegacyAppleHealthImport, + type AppleHealthImportPayload, +} from "@/lib/jobs/apple-health-import-worker"; +import { + handleDawarichSync, + type DawarichSyncPayload, +} from "@/lib/jobs/dawarich-sync"; +import { + handleEnvironmentAqHistory, + type EnvironmentAqHistoryPayload, +} from "@/lib/jobs/environment-air-quality-history"; +import { + handleEnvironmentFetch, + type EnvironmentFetchPayload, +} from "@/lib/jobs/environment-fetch"; +import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome"; +import { + handleMedicationIntakeImport, + type MedicationIntakeImportQueuePayload, +} from "@/lib/jobs/medication-intake-import"; +import { recordError } from "@/lib/jobs/worker-status"; +import { + dedupeUserIntakeSlots, + enqueueBootTimeIntakeSlotDedup, + type IntakeSlotDedupPayload, +} from "@/lib/medications/intake-slot-dedup"; + +import { workerLog } from "./shared"; + +/** + * The Apple Health export ingest. Each import is independent work, so a + * failed one does not abort the rest of the batch; the first failure is what + * the job reports. + */ +export async function handleAppleHealthImportJobs( + jobs: Job[], +): Promise { + let failure: JobOutcome | null = null; + for (const job of jobs) { + const outcome = await handleAppleHealthImport(job); + if (!outcome.ok && failure === null) failure = outcome; + } + return failure ?? jobDone({ jobs: jobs.length }); +} + +/** + * Drains the pre-revision-2 backlog by moving each claimed legacy job onto + * the current queue, keeping the import's status row, so the current parser + * never runs under a revision-1 mirror. + */ +export async function handleLegacyAppleHealthImportJobs( + jobs: Job[], +): Promise { + for (const job of jobs) { + await migrateLegacyAppleHealthImport(job); + } + return jobDone({ jobs: jobs.length }); +} + +/** + * Independent per-delivery medication-intake imports: run them all and report + * the first failure rather than losing it behind a later success. Bound with + * metadata, so a retry resumes from the job's creation time. + */ +export async function handleMedicationIntakeImportJobs( + jobs: JobWithMetadata[], +): Promise { + let failure: JobOutcome | null = null; + for (const job of jobs) { + const outcome = await handleMedicationIntakeImport(job); + if (!outcome.ok && failure === null) failure = outcome; + } + return failure ?? jobDone({ jobs: jobs.length }); +} + +/** + * The duplicate dose-slot cleanup. A job with a `userId` keeps one account's + * winner per slot, soft-deletes the losers and recomputes the affected + * compliance days; the daily tick (no `userId`) runs the same discovery + * fan-out as the boot pass, so slots duplicated between deploys collapse + * within a day. + */ +export async function handleIntakeSlotDedup( + jobs: Job>[], +): Promise { + let users = 0; + let discoveryEnqueued = 0; + let discoverySkipped = 0; + // The discovery helper returns its error instead of throwing, so a failed + // fan-out would otherwise leave no trace on the job at all. + let discoveryFailed = 0; + let slotsCollapsed = 0; + let rowsSoftDeleted = 0; + let rowsNormalised = 0; + let daysRecomputed = 0; + for (const job of jobs) { + const { userId } = job.data; + if (!userId) { + const result = await enqueueBootTimeIntakeSlotDedup(); + discoveryEnqueued += result.enqueued; + discoverySkipped += result.skipped; + if (result.error) discoveryFailed++; + workerLog( + "info", + `[intake-slot-dedup] daily discovery enqueued=${result.enqueued} skipped=${result.skipped}${result.error ? ` error=${result.error}` : ""}`, + ); + continue; + } + try { + const summary = await dedupeUserIntakeSlots(userId); + users++; + slotsCollapsed += summary.slotsCollapsed; + rowsSoftDeleted += summary.rowsSoftDeleted; + rowsNormalised += summary.rowsNormalised; + daysRecomputed += summary.daysRecomputed; + workerLog( + "info", + `[intake-slot-dedup] user=${userId} slotsCollapsed=${summary.slotsCollapsed} rowsSoftDeleted=${summary.rowsSoftDeleted} rowsNormalised=${summary.rowsNormalised} daysRecomputed=${summary.daysRecomputed}`, + ); + } catch (err) { + recordError(); + workerLog("error", `[intake-slot-dedup] user=${userId} failed`, err); + throw err; + } + } + return jobDone({ + jobs: jobs.length, + users, + discovery_enqueued: discoveryEnqueued, + discovery_skipped: discoverySkipped, + discovery_failed: discoveryFailed, + slots_collapsed: slotsCollapsed, + rows_soft_deleted: rowsSoftDeleted, + rows_normalised: rowsNormalised, + days_recomputed: daysRecomputed, + }); +} + +/** The air-quality history backfill; the first failure is what the job reports. */ +export function environmentAqHistoryHandler( + boss: PgBoss, +): (jobs: Job[]) => Promise { + return async (jobs) => { + let failure: JobOutcome | null = null; + for (const job of jobs) { + const outcome = await handleEnvironmentAqHistory(boss, job.data); + if (!outcome.ok && failure === null) failure = outcome; + } + return failure ?? jobDone({ jobs: jobs.length }); + }; +} + +/** + * The environment fetch. A discovery tick and a per-user backfill share the + * queue and are independent, so the pass runs to the end and reports the + * first failure it saw. + */ +export function environmentFetchHandler( + boss: PgBoss, +): (jobs: Job[]) => Promise { + return async (jobs) => { + let failure: JobOutcome | null = null; + for (const job of jobs) { + const outcome = await handleEnvironmentFetch(boss, job.data); + if (!outcome.ok && failure === null) failure = outcome; + } + return failure ?? jobDone({ jobs: jobs.length }); + }; +} + +/** The Dawarich movement context, one account at a time. */ +export function dawarichSyncHandler( + boss: PgBoss, +): (jobs: Job[]) => Promise { + return (jobs) => handleDawarichSync(boss, jobs); +} diff --git a/src/lib/jobs/reminder/register-integration-sync.ts b/src/lib/jobs/reminder/register-integration-sync.ts deleted file mode 100644 index d96055632..000000000 --- a/src/lib/jobs/reminder/register-integration-sync.ts +++ /dev/null @@ -1,1051 +0,0 @@ -/** - * Integration-sync queue registrar. - * - * Owns the third-party-provider sync queues — Withings (fallback / activity / - * sleep), WHOOP (recovery / sleep / workout / cycle + backfill + OAuth-state - * cleanup), Fitbit (sync + backfill + sleep repair + OAuth-state cleanup), - * Nightscout, Polar, Oura, and the two one-shot backfills - * (sleep-timeline, lab-biomarker) — plus their boot-time discovery enqueues. - * - * v1.4.37 dead-queue contract: every queue name appears in `allQueues`, its - * cron (where it has one) appears as a `[QUEUE, CRON]` tuple in `schedules`, - * and a `createAndWork(boss, QUEUE, …, handler)` binding drains it. The - * queue-wiring guards (`withings-queues`, `whoop-queues`, `fitbit-queues`, - * `nightscout-queues`, `sleep-timeline-queues`) read THIS module. - */ -import { PgBoss } from "pg-boss"; -import { - WHOOP_BACKFILL_QUEUE, - WHOOP_BACKFILL_CONCURRENCY, - enqueueBootTimeWhoopBackfill, - type WhoopBackfillPayload, -} from "@/lib/jobs/whoop-backfill"; -import { - FITBIT_BACKFILL_QUEUE, - FITBIT_BACKFILL_CONCURRENCY, - enqueueBootTimeFitbitBackfill, - type FitbitBackfillPayload, -} from "@/lib/jobs/fitbit-backfill"; -import { - GOOGLE_HEALTH_BACKFILL_QUEUE, - GOOGLE_HEALTH_BACKFILL_CONCURRENCY, - enqueueBootTimeGoogleHealthBackfill, - type GoogleHealthBackfillPayload, -} from "@/lib/jobs/google-health-backfill"; -import { - GOOGLE_HEALTH_SLEEP_REPAIR_QUEUE, - GOOGLE_HEALTH_SLEEP_REPAIR_CONCURRENCY, - enqueueBootTimeGoogleHealthSleepRepair, - type GoogleHealthSleepRepairPayload, -} from "@/lib/jobs/google-health-sleep-repair"; -import { - FITBIT_SLEEP_REPAIR_QUEUE, - FITBIT_SLEEP_REPAIR_CONCURRENCY, - enqueueBootTimeFitbitSleepRepair, - type FitbitSleepRepairPayload, -} from "@/lib/jobs/fitbit-sleep-repair"; -import { - STRAVA_BACKFILL_QUEUE, - STRAVA_BACKFILL_CONCURRENCY, - enqueueBootTimeStravaBackfill, - type StravaBackfillPayload, -} from "@/lib/jobs/strava-backfill"; -import { - OURA_BACKFILL_QUEUE, - OURA_BACKFILL_CONCURRENCY, - enqueueBootTimeOuraBackfill, - type OuraBackfillPayload, -} from "@/lib/jobs/oura-backfill"; -import { - SLEEP_TIMELINE_BACKFILL_QUEUE, - SLEEP_TIMELINE_BACKFILL_CONCURRENCY, - enqueueBootTimeSleepTimelineBackfill, - type SleepTimelineBackfillPayload, -} from "@/lib/jobs/sleep-timeline-backfill"; -import { - LAB_BIOMARKER_BACKFILL_QUEUE, - LAB_BIOMARKER_BACKFILL_CONCURRENCY, - enqueueBootTimeLabBiomarkerBackfill, - type LabBiomarkerBackfillPayload, -} from "@/lib/jobs/lab-biomarker-backfill"; -import { - INTEGRATION_BACKFILL_ADMISSION_QUEUE, - INTEGRATION_BACKFILL_GLOBAL_CONCURRENCY, - bootStaggerSecondsFor, - enqueueIntegrationBackfillAdmission, - type IntegrationBackfillAdmissionPayload, -} from "@/lib/jobs/integration-backfill-admission"; -import { drainIntegrationBackfillAdmission } from "@/lib/jobs/integration-backfill-drain"; -import { heavyLane } from "@/lib/jobs/heavy-lane"; -import { - INTEGRATION_WATCHDOG_DAILY_CRON, - INTEGRATION_WATCHDOG_DAILY_QUEUE, - INTEGRATION_WATCHDOG_HOURLY_CRON, - INTEGRATION_WATCHDOG_HOURLY_QUEUE, - handleIntegrationWatchdogDaily, - handleIntegrationWatchdogHourly, - type IntegrationWatchdogPayload, -} from "@/lib/jobs/integration-watchdog"; -import { workerLog } from "./shared"; -import { jobDone } from "@/lib/jobs/job-outcome"; -import { - createAndSchedule, - createAndWork, - cronIsTheRetry, - type QueuePolicyTable, - type ScheduleEntry, -} from "./registrar-shared"; -import { WITHINGS_ECG_SYNC_QUEUE } from "@/lib/jobs/withings-ecg-queue"; -import { - NIGHTSCOUT_SYNC_QUEUE, - OURA_SYNC_QUEUE, - POLAR_SYNC_QUEUE, - STRAVA_SYNC_QUEUE, -} from "@/lib/jobs/integration-poll-queues"; -import { - WithingsSyncPayload, - WithingsActivitySyncPayload, - WithingsSleepSyncPayload, - WithingsEcgSyncPayload, - handleWithingsFallbackSync, - handleWithingsActivitySync, - handleWithingsSleepSync, - handleWithingsEcgSync, -} from "./withings-sync"; -import { - WhoopSyncPayload, - handleWhoopRecoverySync, - handleWhoopSleepSync, - handleWhoopWorkoutSync, - handleWhoopCycleSync, -} from "./whoop-sync"; -import { - WithingsOAuthStateCleanupPayload, - handleWithingsOAuthStateCleanup, - WhoopOAuthStateCleanupPayload, - handleWhoopOAuthStateCleanup, - OidcNativeHandoffCleanupPayload, - handleOidcNativeHandoffCleanup, -} from "./cleanup-handlers"; -import { NightscoutSyncPayload, handleNightscoutSync } from "./nightscout-sync"; -import { PolarSyncPayload, handlePolarSync } from "./polar-sync"; -import { OuraSyncPayload, handleOuraSync } from "./oura-sync"; -import { StravaSyncPayload, handleStravaSync } from "./strava-sync"; -import { - FitbitSyncPayload, - handleFitbitSync, - FitbitOAuthStateCleanupPayload, - handleFitbitOAuthStateCleanup, -} from "./fitbit-sync"; -import { - GoogleHealthSyncPayload, - handleGoogleHealthSync, - GoogleHealthOAuthStateCleanupPayload, - handleGoogleHealthOAuthStateCleanup, -} from "./google-health-sync"; - -const WITHINGS_SYNC_QUEUE = "withings-fallback-sync"; - -const WITHINGS_SYNC_CRON = "0 * * * *"; // every 60 minutes -// v1.4.25 — webhook-primary + cron-safety-net for activity and -// sleep v2. The webhook handler enqueues per-user jobs on appli=16 / 44 -// notifications; the crons below are the catch-net for the 1 % of -// notifications Withings drops. Offset 15-minute cadence per the -// research recommendation so the two queues don't lockstep against the -// existing measure cron at :00. - -const WITHINGS_ACTIVITY_QUEUE = "withings-activity-sync"; - -const WITHINGS_ACTIVITY_CRON = "0 * * * *"; // every hour at :00 - -const WITHINGS_SLEEP_QUEUE = "withings-sleep-sync"; - -const WITHINGS_SLEEP_CRON = "15 * * * *"; // every hour at :15 -// v1.32.23 — Withings ECG cron catch-net. The queue is webhook-primary -// (appli=54 → `enqueueWithingsEcgSync`), but appli 54 is not in the -// subscription set and the manual sync route measures only, so a ScanWatch-only -// account (no scale) never had its ECG strips pulled. An hourly cohort walk -// (one `v2/heart list` call per connected user, empty + cheap for non-ECG -// devices) closes the gap. :41 is a free minute (used: 0,5,8,11,13,15,17,18, -// 20,30,35,50) staggered off every other hourly sync tick. -const WITHINGS_ECG_SYNC_CRON = "41 * * * *"; // every hour at :41 - -// v1.4.47 — daily sweep for the Withings OAuth state ledger. Slots -// at :20 between the audit-log cleanup (:15) and the mood-reminder -// cleanup (:25), inside the existing 02:xx-03:xx maintenance window. -// Rows are normally consumed by the callback handler in single-use -// fashion; this sweep picks up the long tail where a user closed the -// Withings approval tab without bouncing back to the callback URL. - -const WITHINGS_OAUTH_STATE_CLEANUP_QUEUE = "withings-oauth-state-cleanup"; - -const WITHINGS_OAUTH_STATE_CLEANUP_CRON = "20 3 * * *"; -// v1.11.0 — WHOOP sync queues. Webhook-primary + cron-safety-net, mirroring -// the Withings activity/sleep crons. Recovery / sleep / workout each have a -// WHOOP webhook (`*.updated`) that enqueues the matching per-resource job; the -// crons below are the catch-net for dropped deliveries. Cycle has NO webhook, -// so its cron is the only driver. Minutes are staggered off the Withings crons -// (:00/:15) to spread DB load. - -const WHOOP_RECOVERY_SYNC_QUEUE = "whoop-recovery-sync"; - -const WHOOP_RECOVERY_SYNC_CRON = "5 * * * *"; // every hour at :05 - -const WHOOP_SLEEP_SYNC_QUEUE = "whoop-sleep-sync"; - -const WHOOP_SLEEP_SYNC_CRON = "20 * * * *"; // every hour at :20 - -const WHOOP_WORKOUT_SYNC_QUEUE = "whoop-workout-sync"; - -const WHOOP_WORKOUT_SYNC_CRON = "35 * * * *"; // every hour at :35 - -const WHOOP_CYCLE_SYNC_QUEUE = "whoop-cycle-sync"; - -const WHOOP_CYCLE_SYNC_CRON = "50 * * * *"; // every hour at :50 (poll-only) -// v1.11.0 — daily sweep for the WHOOP OAuth state ledger. Slots at 03:22, -// next to the Withings sweep (03:20), inside the maintenance window. - -const WHOOP_OAUTH_STATE_CLEANUP_QUEUE = "whoop-oauth-state-cleanup"; - -const WHOOP_OAUTH_STATE_CLEANUP_CRON = "22 3 * * *"; -// v1.12.0 — Fitbit / Google Health poll-only sync. There is no Fitbit webhook -// at launch (Pub/Sub deferred), so a single hourly cron drives the per-user -// `syncUserFitbit` driver across every connection. Minute staggered off the -// WHOOP slots (:05/:20/:35/:50) and the Withings slots (:00/:15) so the hourly -// ticks don't pile up on one boss poll. - -const FITBIT_SYNC_QUEUE = "fitbit-sync"; - -const FITBIT_SYNC_CRON = "8 * * * *"; // every hour at :08 -// v1.12.0 — daily sweep for the Fitbit OAuth state ledger. Slots at 03:24, next -// to the WHOOP sweep (03:22), inside the maintenance window. - -const FITBIT_OAUTH_STATE_CLEANUP_QUEUE = "fitbit-oauth-state-cleanup"; - -const FITBIT_OAUTH_STATE_CLEANUP_CRON = "24 3 * * *"; -// v1.26.0 — Google Health / Fitbit-Pixel-Wear-OS poll-only sync. There is no -// Google Health webhook at launch (Pub/Sub deferred), so a single hourly cron -// drives the per-user `syncUserGoogleHealth` driver across every connection. -// Minute staggered off the WHOOP (:05/:20/:35/:50), Fitbit (:08), Nightscout -// (:11), Polar (:13), and Oura (:15) slots so the hourly ticks don't pile up on -// one boss poll. - -const GOOGLE_HEALTH_SYNC_QUEUE = "google-health-sync"; - -const GOOGLE_HEALTH_SYNC_CRON = "18 * * * *"; // every hour at :18 -// v1.26.0 — daily sweep for the Google Health OAuth state ledger. Slots at -// 03:26, next to the Fitbit sweep (03:24), inside the maintenance window. - -const GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_QUEUE = - "google-health-oauth-state-cleanup"; - -const GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_CRON = "26 3 * * *"; -// v1.17.0 — Nightscout CGM poll sync. Poll-only (no webhook): one hourly tick -// pulls the recent SGV window per configured instance. :11 staggers off the -// WHOOP (:05), Fitbit (:08), and Withings sync ticks so the hourly polls don't -// pile up on one boss poll. -// -// The four poll-queue NAMES live in `@/lib/jobs/integration-poll-queues` (see -// the import above) so an OAuth callback can enqueue a connect-time first sync -// without importing this registrar and its whole handler graph. Creation, -// scheduling, and handler binding stay here. -const NIGHTSCOUT_SYNC_CRON = "11 * * * *"; // every hour at :11 -// v1.17.0 — Polar + Oura OAuth poll sync. Poll-only (one hourly tick per -// provider re-walks every connected user). :13 / :15 stagger off the other -// hourly sync ticks (WHOOP :05, Fitbit :08, Nightscout :11) so the polls don't -// pile up on one boss poll. The queues MUST be registered in `allQueues` below -// or pg-boss never provisions them and the schedule silently no-ops (the -// v1.4.37 dead-queue class). -const POLAR_SYNC_CRON = "13 * * * *"; // every hour at :13 -const OURA_SYNC_CRON = "15 * * * *"; // every hour at :15 -// v1.28.x — Strava OAuth poll sync. Poll-only (webhook deferred): one hourly -// tick re-walks every connected user. :17 staggers off the Polar (:13) / Oura -// (:15) ticks so the polls don't pile up on one boss poll. The queue MUST be -// registered in `allQueues` below or pg-boss never provisions it and the -// schedule silently no-ops (the v1.4.37 dead-queue class). -const STRAVA_SYNC_CRON = "17 * * * *"; // every hour at :17 -// v1.30.x — daily sweep for the native OIDC handoff ledger. Handoff codes are -// single-use + 90s-lived and expiry is enforced at read, so this is pure -// hygiene: it reaps rows the app never came back to exchange. Slots at 03:28, -// next to the Google Health OAuth-state sweep (03:26), inside the maintenance -// window. The queue MUST be registered in `allQueues` below or pg-boss never -// provisions it and the schedule silently no-ops (the v1.4.37 dead-queue class). -const OIDC_NATIVE_HANDOFF_CLEANUP_QUEUE = "oidc-native-handoff-cleanup"; -const OIDC_NATIVE_HANDOFF_CLEANUP_CRON = "28 3 * * *"; - -// pg-boss v12 requires explicit queue creation before scheduling. Every queue -// MUST be registered here or pg-boss never provisions it and both the webhook -// enqueue AND the cron schedule below silently no-op (the v1.4.37 dead-queue -// class). -const allQueues = [ - WITHINGS_SYNC_QUEUE, - WITHINGS_ACTIVITY_QUEUE, - WITHINGS_SLEEP_QUEUE, - WITHINGS_ECG_SYNC_QUEUE, - WITHINGS_OAUTH_STATE_CLEANUP_QUEUE, - // v1.11.0 — WHOOP sync queues. Webhook-primary + cron-safety-net for - // recovery / sleep / workout; cycle is poll-only (no WHOOP webhook). - WHOOP_RECOVERY_SYNC_QUEUE, - WHOOP_SLEEP_SYNC_QUEUE, - WHOOP_WORKOUT_SYNC_QUEUE, - WHOOP_CYCLE_SYNC_QUEUE, - // v1.11.0 — self-converging boot backfill for newly connected WHOOP - // accounts. Discovery enqueues one full-history sync per un-backfilled - // connection; idempotent across reboots. - WHOOP_BACKFILL_QUEUE, - // v1.11.0 — daily sweep for the WHOOP OAuth state ledger. - WHOOP_OAUTH_STATE_CLEANUP_QUEUE, - // v1.12.0 — Fitbit / Google Health poll-only sync (no webhook at launch), - // self-converging boot backfill, and the daily OAuth-state ledger sweep. - FITBIT_SYNC_QUEUE, - FITBIT_BACKFILL_QUEUE, - // One-shot Fitbit sleep duplicate repair. Discovery enqueues one bounded - // sleep re-read per connection not yet repaired; the replace-by-window write - // path collapses each night's leftovers. Idempotent across reboots. - FITBIT_SLEEP_REPAIR_QUEUE, - FITBIT_OAUTH_STATE_CLEANUP_QUEUE, - // v1.26.0 — Google Health poll-only sync (no webhook at launch), - // self-converging boot backfill, and the daily OAuth-state ledger sweep. - GOOGLE_HEALTH_SYNC_QUEUE, - GOOGLE_HEALTH_BACKFILL_QUEUE, - // v1.28.x — one-shot sleep duplicate repair. Discovery enqueues one full - // sleep re-read per connection not yet repaired; the replace-by-window write - // path collapses each night's duplicate rows. Idempotent across reboots. - GOOGLE_HEALTH_SLEEP_REPAIR_QUEUE, - GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_QUEUE, - // v1.17.1 — one-shot sleep-timeline backfill for WHOOP + Withings. - // Discovery enqueues one job per connection whose sleep rows predate the - // stamp/shape fix; the pass deletes the affected SLEEP_DURATION rows and - // re-syncs. Idempotent across reboots. - SLEEP_TIMELINE_BACKFILL_QUEUE, - // v1.18.1 — one-shot backfill that links legacy free-text lab readings to - // a user-scoped Biomarker catalog entry (group by `lower(analyte)`). - LAB_BIOMARKER_BACKFILL_QUEUE, - // Shared durable drain for every full-history integration job. Source queues - // retain their per-connection singleton keys; this queue supplies the one - // cross-provider execution lane. - INTEGRATION_BACKFILL_ADMISSION_QUEUE, - // v1.17.0 — Nightscout CGM poll sync. Poll-only (no webhook, no OAuth, no - // backfill queue — the hourly window walks the recent SGV set). - NIGHTSCOUT_SYNC_QUEUE, - // v1.17.0 — Polar + Oura OAuth poll sync. - POLAR_SYNC_QUEUE, - OURA_SYNC_QUEUE, - // v1.44 — Oura history backfill, sliced through the shared lane. - OURA_BACKFILL_QUEUE, - // v1.28.x — Strava OAuth poll sync + self-converging boot backfill. - STRAVA_SYNC_QUEUE, - STRAVA_BACKFILL_QUEUE, - // v1.30.x — daily sweep for the native OIDC handoff ledger. - OIDC_NATIVE_HANDOFF_CLEANUP_QUEUE, - // v1.42.4 — the integration watchdog: an hourly cheap pass and a daily - // pass that re-reads the history behind standing silence flags. - INTEGRATION_WATCHDOG_HOURLY_QUEUE, - INTEGRATION_WATCHDOG_DAILY_QUEUE, -]; - -const schedules: ScheduleEntry[] = [ - [WITHINGS_SYNC_QUEUE, WITHINGS_SYNC_CRON], - [WITHINGS_ACTIVITY_QUEUE, WITHINGS_ACTIVITY_CRON], - [WITHINGS_SLEEP_QUEUE, WITHINGS_SLEEP_CRON], - // v1.32.23 — hourly ECG catch-net (:41) so watch-only accounts pull their - // strips without a webhook or a scale. - [WITHINGS_ECG_SYNC_QUEUE, WITHINGS_ECG_SYNC_CRON], - // The five OAuth-state / handoff prunes carry `cronIsTheRetry`: their - // handlers used to warn and carry on, they now fail the job, and a DELETE - // that could not run will not run any better a second later. The daily tick - // is the retry. The sync crons above keep the default retries — a provider - // outage is transient and worth retrying inside the hour. - [ - WITHINGS_OAUTH_STATE_CLEANUP_QUEUE, - WITHINGS_OAUTH_STATE_CLEANUP_CRON, - cronIsTheRetry, - ], - // v1.11.0 — WHOOP poll-fallback crons. Recovery/sleep/workout catch - // dropped webhooks; cycle is the sole driver (no webhook). Staggered off - // the Withings crons so the hourly ticks don't pile up on one boss poll. - [WHOOP_RECOVERY_SYNC_QUEUE, WHOOP_RECOVERY_SYNC_CRON], - [WHOOP_SLEEP_SYNC_QUEUE, WHOOP_SLEEP_SYNC_CRON], - [WHOOP_WORKOUT_SYNC_QUEUE, WHOOP_WORKOUT_SYNC_CRON], - [WHOOP_CYCLE_SYNC_QUEUE, WHOOP_CYCLE_SYNC_CRON], - // v1.11.0 — daily 03:22 Europe/Berlin prune for expired WHOOP OAuth states. - [ - WHOOP_OAUTH_STATE_CLEANUP_QUEUE, - WHOOP_OAUTH_STATE_CLEANUP_CRON, - cronIsTheRetry, - ], - // v1.12.0 — hourly Fitbit poll (:08, staggered off WHOOP/Withings) + the - // daily 03:24 Europe/Berlin prune for expired Fitbit OAuth states. - [FITBIT_SYNC_QUEUE, FITBIT_SYNC_CRON], - [ - FITBIT_OAUTH_STATE_CLEANUP_QUEUE, - FITBIT_OAUTH_STATE_CLEANUP_CRON, - cronIsTheRetry, - ], - // v1.26.0 — hourly Google Health poll (:18, staggered off WHOOP/Fitbit/ - // Nightscout/Polar/Oura) + the daily 03:26 Europe/Berlin prune for expired - // Google Health OAuth states. - [GOOGLE_HEALTH_SYNC_QUEUE, GOOGLE_HEALTH_SYNC_CRON], - [ - GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_QUEUE, - GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_CRON, - cronIsTheRetry, - ], - // v1.17.0 — hourly Nightscout CGM poll (:11, staggered off the other sync - // ticks). - [NIGHTSCOUT_SYNC_QUEUE, NIGHTSCOUT_SYNC_CRON], - // v1.17.0 — hourly Polar (:13) + Oura (:15) OAuth polls. - [POLAR_SYNC_QUEUE, POLAR_SYNC_CRON], - [OURA_SYNC_QUEUE, OURA_SYNC_CRON], - // v1.28.x — hourly Strava (:17) OAuth poll. - [STRAVA_SYNC_QUEUE, STRAVA_SYNC_CRON], - // v1.42.4 — integration watchdog, hourly at :47 and daily at 04:52. The - // next tick is the retry: a missed pass only delays a flag by an hour. - [ - INTEGRATION_WATCHDOG_HOURLY_QUEUE, - INTEGRATION_WATCHDOG_HOURLY_CRON, - cronIsTheRetry, - ], - [ - INTEGRATION_WATCHDOG_DAILY_QUEUE, - INTEGRATION_WATCHDOG_DAILY_CRON, - cronIsTheRetry, - ], - // v1.30.x — daily 03:28 Europe/Berlin prune for expired native OIDC handoffs. - [ - OIDC_NATIVE_HANDOFF_CLEANUP_QUEUE, - OIDC_NATIVE_HANDOFF_CLEANUP_CRON, - cronIsTheRetry, - ], -]; - -/** - * De-duplication policy per integration-sync queue. - * - * The recurring poll queues (Withings / WHOOP / Fitbit / Google Health / - * Nightscout / Polar / Oura / Strava sync, and every OAuth-state cleanup) are - * keyless cron ticks — no `singletonKey` on any send — so a policy would only - * constrain the empty key. They are deliberately absent. - * - * The one-shot backfills and repairs below all share the same shape: one job - * per connection, keyed per user (per provider where a provider can differ), - * enqueued by a boot-discovery predicate that only stops offering the job once - * it has COMPLETED. That last property is what made the bug bite — a worker - * restarting during a heavy account's multi-hour full-history pass appended - * another identical job per restart, sustaining the boot storm the stagger - * deferrals exist to damp. `exclusive` covers queued, active, and retry-backoff, - * so the restart case is closed; and because discovery re-offers the job while - * the work is still outstanding, a suppressed send can never lose work. - */ -const queuePolicies: QueuePolicyTable = { - [WITHINGS_ECG_SYNC_QUEUE]: { - policy: "short", - reason: - "Webhook retries share one identity while queued; once work starts, a later replay remains admissible as a rescue if the original exhausts retries.", - }, - [WHOOP_BACKFILL_QUEUE]: { - policy: "exclusive", - reason: - "Per-connection full-history backfill; long-running, and boot discovery re-enqueues until the connection is marked backfilled.", - }, - [FITBIT_BACKFILL_QUEUE]: { - policy: "exclusive", - reason: - "Per-connection full-history backfill; boot-discovery driven and self-converging.", - }, - [GOOGLE_HEALTH_BACKFILL_QUEUE]: { - policy: "exclusive", - reason: - "Per-connection full-history backfill; boot-discovery driven and self-converging.", - }, - [STRAVA_BACKFILL_QUEUE]: { - policy: "exclusive", - reason: - "Per-connection full-history backfill; boot-discovery driven and self-converging.", - }, - [OURA_BACKFILL_QUEUE]: { - policy: "exclusive", - reason: - "Per-connection full-history backfill; boot-discovery driven and self-converging.", - }, - [GOOGLE_HEALTH_SLEEP_REPAIR_QUEUE]: { - policy: "exclusive", - reason: - "Per-connection one-shot sleep re-read; discovery drops the connection once it is marked repaired.", - }, - [FITBIT_SLEEP_REPAIR_QUEUE]: { - policy: "exclusive", - reason: - "Per-connection one-shot sleep re-read; discovery drops the connection once it is marked repaired.", - }, - [SLEEP_TIMELINE_BACKFILL_QUEUE]: { - policy: "exclusive", - reason: - "Per-connection one-shot timeline backfill, keyed per provider+user; discovery drops the connection once its rows carry the new shape.", - }, - [LAB_BIOMARKER_BACKFILL_QUEUE]: { - policy: "exclusive", - reason: - "Per-user one-shot catalog link-up; discovery drops the user once no unlinked lab readings remain.", - }, - [INTEGRATION_BACKFILL_ADMISSION_QUEUE]: { - policy: "exclusive", - reason: - "Shared full-history drain; keyed per source job so boot rediscovery cannot duplicate queued, active, or retrying work.", - }, -}; - -/** - * Register every integration-sync queue: create, schedule, and bind handlers. - * Returns the queue names created (for the boot-level aggregate assertion). - */ -export async function registerIntegrationSyncQueues( - boss: PgBoss, -): Promise { - await createAndSchedule(boss, allQueues, schedules, queuePolicies); - // pg-boss scopes groupConcurrency to one queue name, so every provider - // reaches this shared durable queue before doing full-history work. The - // common group is enforced in PostgreSQL across worker processes/nodes; - // the heavy lane keeps an import off the heap while a backup, a restore - // or an upload is on it. - - await createAndWork( - boss, - INTEGRATION_BACKFILL_ADMISSION_QUEUE, - { - localConcurrency: INTEGRATION_BACKFILL_GLOBAL_CONCURRENCY, - groupConcurrency: INTEGRATION_BACKFILL_GLOBAL_CONCURRENCY, - }, - heavyLane( - INTEGRATION_BACKFILL_ADMISSION_QUEUE, - drainIntegrationBackfillAdmission, - ), - ); - - await createAndWork( - boss, - WITHINGS_SYNC_QUEUE, - { localConcurrency: 1 }, - handleWithingsFallbackSync, - ); - await createAndWork( - boss, - WITHINGS_ACTIVITY_QUEUE, - { localConcurrency: 1 }, - handleWithingsActivitySync, - ); - await createAndWork( - boss, - WITHINGS_SLEEP_QUEUE, - { localConcurrency: 1 }, - handleWithingsSleepSync, - ); - await createAndWork( - boss, - WITHINGS_ECG_SYNC_QUEUE, - { localConcurrency: 1 }, - handleWithingsEcgSync, - ); - await createAndWork( - boss, - WITHINGS_OAUTH_STATE_CLEANUP_QUEUE, - { localConcurrency: 1 }, - handleWithingsOAuthStateCleanup, - ); - // v1.11.0 — WHOOP per-resource sync handlers. Webhook-driven per-user + - // cron full-iteration. Serial concurrency so a backfill-heavy tick never - // crowds the request pool and stays inside WHOOP's 100 req/min app cap. - await createAndWork( - boss, - WHOOP_RECOVERY_SYNC_QUEUE, - { localConcurrency: 1 }, - handleWhoopRecoverySync, - ); - await createAndWork( - boss, - WHOOP_SLEEP_SYNC_QUEUE, - { localConcurrency: 1 }, - handleWhoopSleepSync, - ); - await createAndWork( - boss, - WHOOP_WORKOUT_SYNC_QUEUE, - { localConcurrency: 1 }, - handleWhoopWorkoutSync, - ); - await createAndWork( - boss, - WHOOP_CYCLE_SYNC_QUEUE, - { localConcurrency: 1 }, - handleWhoopCycleSync, - ); - // v1.11.0 — self-converging WHOOP backfill. This source queue retains the - // per-connection singleton and forwards admitted work to the shared drain. - await createAndWork( - boss, - WHOOP_BACKFILL_QUEUE, - { localConcurrency: WHOOP_BACKFILL_CONCURRENCY }, - async (jobs) => { - for (const job of jobs) { - await enqueueIntegrationBackfillAdmission(boss, { - kind: "whoop-backfill", - data: job.data, - }); - } - return jobDone({ jobs: jobs.length }); - }, - ); - await createAndWork( - boss, - WHOOP_OAUTH_STATE_CLEANUP_QUEUE, - { localConcurrency: 1 }, - handleWhoopOAuthStateCleanup, - ); - // v1.30.x — daily sweep for the native OIDC handoff ledger. - await createAndWork( - boss, - OIDC_NATIVE_HANDOFF_CLEANUP_QUEUE, - { localConcurrency: 1 }, - handleOidcNativeHandoffCleanup, - ); - // v1.12.0 — Fitbit poll-sync (cron full-iteration; no webhook). Serial - // concurrency so a backfill-heavy tick never crowds the request pool. - await createAndWork( - boss, - FITBIT_SYNC_QUEUE, - { localConcurrency: 1 }, - handleFitbitSync, - ); - // v1.12.0 — self-converging Fitbit backfill. This source queue retains the - // per-connection singleton and forwards admitted work to the shared drain. - await createAndWork( - boss, - FITBIT_BACKFILL_QUEUE, - { localConcurrency: FITBIT_BACKFILL_CONCURRENCY }, - async (jobs) => { - for (const job of jobs) { - await enqueueIntegrationBackfillAdmission(boss, { - kind: "fitbit-backfill", - data: job.data, - }); - } - return jobDone({ jobs: jobs.length }); - }, - ); - // One-shot Fitbit sleep duplicate repair. This source queue retains the - // per-connection singleton and forwards the watermark-safe bounded sleep - // re-read to the shared drain. - await createAndWork( - boss, - FITBIT_SLEEP_REPAIR_QUEUE, - { localConcurrency: FITBIT_SLEEP_REPAIR_CONCURRENCY }, - async (jobs) => { - for (const job of jobs) { - await enqueueIntegrationBackfillAdmission(boss, { - kind: "fitbit-sleep-repair", - data: job.data, - }); - } - return jobDone({ jobs: jobs.length }); - }, - ); - await createAndWork( - boss, - FITBIT_OAUTH_STATE_CLEANUP_QUEUE, - { localConcurrency: 1 }, - handleFitbitOAuthStateCleanup, - ); - // v1.26.0 — Google Health poll-sync (cron full-iteration; no webhook). Serial - // concurrency so a backfill-heavy tick never crowds the request pool. - await createAndWork( - boss, - GOOGLE_HEALTH_SYNC_QUEUE, - { localConcurrency: 1 }, - handleGoogleHealthSync, - ); - // v1.26.0 — self-converging Google Health backfill. This source queue - // retains the per-connection singleton and forwards admitted work to the - // shared drain. - await createAndWork( - boss, - GOOGLE_HEALTH_BACKFILL_QUEUE, - { localConcurrency: GOOGLE_HEALTH_BACKFILL_CONCURRENCY }, - async (jobs) => { - for (const job of jobs) { - await enqueueIntegrationBackfillAdmission(boss, { - kind: "google-health-backfill", - data: job.data, - }); - } - return jobDone({ jobs: jobs.length }); - }, - ); - // v1.28.x — one-shot Google Health sleep duplicate repair. This source - // queue retains the per-connection singleton and forwards the watermark-safe - // full sleep-history re-read to the shared drain. - await createAndWork( - boss, - GOOGLE_HEALTH_SLEEP_REPAIR_QUEUE, - { localConcurrency: GOOGLE_HEALTH_SLEEP_REPAIR_CONCURRENCY }, - async (jobs) => { - for (const job of jobs) { - await enqueueIntegrationBackfillAdmission(boss, { - kind: "google-health-sleep-repair", - data: job.data, - }); - } - return jobDone({ jobs: jobs.length }); - }, - ); - await createAndWork( - boss, - GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_QUEUE, - { localConcurrency: 1 }, - handleGoogleHealthOAuthStateCleanup, - ); - // v1.17.1 — one-shot sleep-timeline backfill. This source queue retains the - // per-(user, provider) singleton and forwards admitted work to the shared - // drain. - await createAndWork( - boss, - SLEEP_TIMELINE_BACKFILL_QUEUE, - { localConcurrency: SLEEP_TIMELINE_BACKFILL_CONCURRENCY }, - async (jobs) => { - for (const job of jobs) { - await enqueueIntegrationBackfillAdmission(boss, { - kind: "sleep-timeline-backfill", - data: job.data, - }); - } - return jobDone({ jobs: jobs.length }); - }, - ); - // v1.18.1 — one-shot lab-biomarker backfill. This source queue retains the - // per-user singleton and forwards admitted work to the shared drain. - await createAndWork( - boss, - LAB_BIOMARKER_BACKFILL_QUEUE, - { localConcurrency: LAB_BIOMARKER_BACKFILL_CONCURRENCY }, - async (jobs) => { - for (const job of jobs) { - await enqueueIntegrationBackfillAdmission(boss, { - kind: "lab-biomarker-backfill", - data: job.data, - }); - } - return jobDone({ jobs: jobs.length }); - }, - ); - // v1.17.0 — Nightscout CGM poll-cohort sync. The hourly cron tick (no - // `userId`) walks every configured instance; one user's unreachable host is - // warned, not fatal. - await createAndWork( - boss, - NIGHTSCOUT_SYNC_QUEUE, - { localConcurrency: 1 }, - handleNightscoutSync, - ); - // v1.17.0 — Polar + Oura OAuth poll-cohort sync. The hourly cron tick - // (no `userId`) re-walks every connected user; one user's revoked grant is - // warned, not fatal. - await createAndWork( - boss, - POLAR_SYNC_QUEUE, - { localConcurrency: 1 }, - handlePolarSync, - ); - await createAndWork( - boss, - OURA_SYNC_QUEUE, - { localConcurrency: 1 }, - handleOuraSync, - ); - // v1.28.x — Strava OAuth poll-cohort sync (poll-only; webhook deferred). The - // hourly cron tick (no `userId`) re-walks every connected user; one user's - // revoked grant is warned, not fatal. - await createAndWork( - boss, - STRAVA_SYNC_QUEUE, - { localConcurrency: 1 }, - handleStravaSync, - ); - // v1.42.4 — integration watchdog. Single-flight per process; both queues - // also share one advisory lock, so the daily and hourly passes never run - // side by side on any replica. - await createAndWork( - boss, - INTEGRATION_WATCHDOG_HOURLY_QUEUE, - { localConcurrency: 1 }, - handleIntegrationWatchdogHourly, - ); - await createAndWork( - boss, - INTEGRATION_WATCHDOG_DAILY_QUEUE, - { localConcurrency: 1 }, - handleIntegrationWatchdogDaily, - ); - // v1.28.x — self-converging Strava backfill. This source queue retains the - // per-connection singleton and forwards admitted work to the shared drain. - await createAndWork( - boss, - STRAVA_BACKFILL_QUEUE, - { localConcurrency: STRAVA_BACKFILL_CONCURRENCY }, - async (jobs) => { - for (const job of jobs) { - await enqueueIntegrationBackfillAdmission(boss, { - kind: "strava-backfill", - data: job.data, - }); - } - return jobDone({ jobs: jobs.length }); - }, - ); - // v1.44 — Oura history backfill. The source queue keeps the per-account - // singleton and forwards admitted work to the shared drain, where it runs - // in resumable slices. - await createAndWork( - boss, - OURA_BACKFILL_QUEUE, - { localConcurrency: OURA_BACKFILL_CONCURRENCY }, - async (jobs) => { - for (const job of jobs) { - await enqueueIntegrationBackfillAdmission(boss, { - kind: "oura-backfill", - data: job.data, - }); - } - return jobDone({ jobs: jobs.length }); - }, - ); - return allQueues; -} - -/** - * Fire-and-forget boot discovery for the self-converging integration backfills. - * Each pass is idempotent across reboots and never fails worker boot on a miss - * (errors come back through the helper's result value). - */ -export async function enqueueIntegrationSyncBootDiscovery(): Promise { - // v1.11.0 — WHOOP backfill. Finds every WHOOP connection not yet backfilled - // and enqueues one full-history sync per account. A completed backfill stamps - // `backfillCompletedAt`, dropping the connection from the discovery set. - try { - const { enqueued, skipped, error } = await enqueueBootTimeWhoopBackfill( - bootStaggerSecondsFor("whoop-backfill"), - ); - if (error) { - workerLog("error", `[whoop-backfill] boot discovery failed: ${error}`); - } else { - workerLog( - "info", - `[whoop-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`, - ); - } - } catch (err) { - workerLog( - "error", - "[whoop-backfill] boot discovery threw an unexpected error", - err, - ); - } - - // v1.12.0 — Fitbit backfill. Finds every Fitbit connection not yet - // backfilled and enqueues one full-history sync per account. - try { - const { enqueued, skipped, error } = await enqueueBootTimeFitbitBackfill( - bootStaggerSecondsFor("fitbit-backfill"), - ); - if (error) { - workerLog("error", `[fitbit-backfill] boot discovery failed: ${error}`); - } else { - workerLog( - "info", - `[fitbit-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`, - ); - } - } catch (err) { - workerLog( - "error", - "[fitbit-backfill] boot discovery threw an unexpected error", - err, - ); - } - - // v1.26.0 — Google Health backfill. Finds every Google Health connection not - // yet backfilled and enqueues one full-history sync per account. - try { - const { enqueued, skipped, error } = - await enqueueBootTimeGoogleHealthBackfill( - bootStaggerSecondsFor("google-health-backfill"), - ); - if (error) { - workerLog( - "error", - `[google-health-backfill] boot discovery failed: ${error}`, - ); - } else { - workerLog( - "info", - `[google-health-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`, - ); - } - } catch (err) { - workerLog( - "error", - "[google-health-backfill] boot discovery threw an unexpected error", - err, - ); - } - - // v1.28.x — one-shot Google Health sleep duplicate repair. Finds every - // connection not yet repaired and enqueues one full sleep re-read per - // account; a completed pass stamps `sleepRepairedAt`. - try { - const { enqueued, skipped, error } = - await enqueueBootTimeGoogleHealthSleepRepair( - bootStaggerSecondsFor("google-health-sleep-repair"), - ); - if (error) { - workerLog( - "error", - `[google-health-sleep-repair] boot discovery failed: ${error}`, - ); - } else { - workerLog( - "info", - `[google-health-sleep-repair] boot discovery: enqueued=${enqueued} skipped=${skipped}`, - ); - } - } catch (err) { - workerLog( - "error", - "[google-health-sleep-repair] boot discovery threw an unexpected error", - err, - ); - } - - // v1.17.1 — one-shot sleep-timeline backfill. Finds every WHOOP + Withings - // connection whose sleep rows predate the stamp/shape fix and enqueues one - // job per (user, provider). A completed pass stamps `sleepTimelineBackfillAt`. - try { - const { enqueued, skipped, error } = - await enqueueBootTimeSleepTimelineBackfill( - bootStaggerSecondsFor("sleep-timeline-backfill"), - ); - if (error) { - workerLog( - "error", - `[sleep-timeline-backfill] boot discovery failed: ${error}`, - ); - } else { - workerLog( - "info", - `[sleep-timeline-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`, - ); - } - } catch (err) { - workerLog( - "error", - "[sleep-timeline-backfill] boot discovery threw an unexpected error", - err, - ); - } - - // v1.28.x — Strava backfill. Finds every Strava connection not yet - // backfilled and enqueues one full-history sync per account. - try { - const { enqueued, skipped, error } = await enqueueBootTimeStravaBackfill( - bootStaggerSecondsFor("strava-backfill"), - ); - if (error) { - workerLog("error", `[strava-backfill] boot discovery failed: ${error}`); - } else { - workerLog( - "info", - `[strava-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`, - ); - } - } catch (err) { - workerLog( - "error", - "[strava-backfill] boot discovery threw an unexpected error", - err, - ); - } - - // v1.44 — Oura history backfill. Finds every Oura connection not yet - // backfilled (and every account still holding the retired - // equivalent-distance rows) and enqueues one sliced backfill per account. - try { - const { enqueued, skipped, error } = await enqueueBootTimeOuraBackfill( - bootStaggerSecondsFor("oura-backfill"), - ); - if (error) { - workerLog("error", `[oura-backfill] boot discovery failed: ${error}`); - } else { - workerLog( - "info", - `[oura-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`, - ); - } - } catch (err) { - workerLog( - "error", - "[oura-backfill] boot discovery threw an unexpected error", - err, - ); - } - - // v1.18.1 — one-shot lab-biomarker backfill. Finds every user holding an - // un-linked live lab reading and enqueues one job each. A completed pass - // links every reading, dropping the user from the discovery set. - try { - const { enqueued, skipped, error } = - await enqueueBootTimeLabBiomarkerBackfill( - bootStaggerSecondsFor("lab-biomarker-backfill"), - ); - if (error) { - workerLog( - "error", - `[lab-biomarker-backfill] boot discovery failed: ${error}`, - ); - } else { - workerLog( - "info", - `[lab-biomarker-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`, - ); - } - } catch (err) { - workerLog( - "error", - "[lab-biomarker-backfill] boot discovery threw an unexpected error", - err, - ); - } - - // One-shot Fitbit sleep duplicate repair. Finds every connection not yet - // repaired and enqueues one bounded sleep re-read per account; a completed - // pass stamps `sleepRepairedAt`. - try { - const { enqueued, skipped, error } = await enqueueBootTimeFitbitSleepRepair( - bootStaggerSecondsFor("fitbit-sleep-repair"), - ); - if (error) { - workerLog( - "error", - `[fitbit-sleep-repair] boot discovery failed: ${error}`, - ); - } else { - workerLog( - "info", - `[fitbit-sleep-repair] boot discovery: enqueued=${enqueued} skipped=${skipped}`, - ); - } - } catch (err) { - workerLog( - "error", - "[fitbit-sleep-repair] boot discovery threw an unexpected error", - err, - ); - } -} diff --git a/src/lib/jobs/reminder/register-maintenance.ts b/src/lib/jobs/reminder/register-maintenance.ts deleted file mode 100644 index b0e02c042..000000000 --- a/src/lib/jobs/reminder/register-maintenance.ts +++ /dev/null @@ -1,2197 +0,0 @@ -/** - * Maintenance / ops / cleanup queue registrar. - * - * Owns the housekeeping tier: data backup (in-DB weekly + off-host nightly), - * the restore drill, every ledger cleanup (rate-limit, idempotency, audit-log, - * mood-reminder, push-attempt, measurement-tombstone), medication inventory - * expiry, intake auto-skip, the duplicate dose-slot dedup (boot + daily-cron), - * host-metric sampling, the rec-feedback aggregator, the geo backfill, the TLS - * pin monitor, PR detection, and the Apple Health export ingest. - * - * v1.4.37 dead-queue contract: every queue name appears in `allQueues`, its - * cron (where it has one) appears as a `[QUEUE, CRON]` tuple in `schedules`, - * and a `createAndWork(boss, QUEUE, …, handler)` binding drains it. The - * `measurement-tombstone-cleanup`, `withings-oauth-state-cleanup`, - * `intake-slot-dedup-queue`, `tls-pin-monitor-queue`, and `geo-backfill` - * guards read THIS module. - */ -import { trackBackgroundTask } from "@/lib/logging/background-tasks"; -import { PgBoss } from "pg-boss"; -import { GEO_BACKFILL_QUEUE, GEO_BACKFILL_CRON } from "@/lib/jobs/geo-backfill"; -import { - GEOLITE2_FETCH_QUEUE, - GEOLITE2_FETCH_CRON, - enqueueGeolite2FetchBootDiscovery, - type Geolite2FetchPayload, -} from "@/lib/jobs/geolite2-fetch"; -import { - TLS_PIN_MONITOR_QUEUE, - TLS_PIN_MONITOR_CRON, -} from "@/lib/jobs/tls-pin-monitor"; -import { - PR_DETECTION_QUEUE, - PR_DETECTION_CONCURRENCY, - PR_DETECTION_FALLBACK_CRON, - PR_DETECTION_EXPIRE_SECONDS, - type PrDetectionPayload, -} from "@/lib/jobs/pr-detection"; -import { jobBudget } from "@/lib/jobs/job-budget"; -import { lockedPass, WHOLE_PASS } from "@/lib/jobs/long-pass"; -import { heavyLane } from "@/lib/jobs/heavy-lane"; -import { - OFFHOST_BACKUP_QUEUE, - OFFHOST_BACKUP_SEND_OPTIONS, -} from "@/lib/jobs/offhost-backup"; -import { - MEDICATION_INVENTORY_EXPIRE_QUEUE, - MEDICATION_INVENTORY_EXPIRE_CRON, - type MedicationInventoryExpirePayload, -} from "@/lib/jobs/medication-inventory-expire"; -import { - INTAKE_AUTO_SKIP_QUEUE, - INTAKE_AUTO_SKIP_CRON, - type IntakeAutoSkipPayload, -} from "@/lib/jobs/intake-auto-skip"; -import { - INTAKE_AUTO_TAKE_QUEUE, - INTAKE_AUTO_TAKE_CRON, - type IntakeAutoTakePayload, -} from "@/lib/jobs/intake-auto-take"; -import { - APPLE_HEALTH_IMPORT_V2_QUEUE, - APPLE_HEALTH_IMPORT_LEGACY_QUEUE, - APPLE_HEALTH_IMPORT_CONCURRENCY, - IMPORT_JOB_RECONCILE_QUEUE, - IMPORT_JOB_RECONCILE_CRON, - handleAppleHealthImport, - handleImportJobReconcileTick, - migrateLegacyAppleHealthImport, - type AppleHealthImportPayload, -} from "@/lib/jobs/apple-health-import-worker"; -import { - COMPACTION_TOMBSTONE_PURGE_QUEUE, - enqueueBootTimeCompactionTombstonePurge, - handleCompactionTombstonePurge, - type CompactionTombstonePurgePayload, -} from "@/lib/jobs/compaction-tombstone-purge"; -import { - MEASUREMENT_FOLD_REPAIR_QUEUE, - enqueueBootTimeMeasurementFoldRepair, - handleMeasurementFoldRepair, - type MeasurementFoldRepairPayload, -} from "@/lib/jobs/measurement-fold-repair"; -import { - MEASUREMENT_MAINTENANCE_CRON, - MEASUREMENT_MAINTENANCE_QUEUE, - MEASUREMENT_MAINTENANCE_SEND_OPTIONS, - handleMeasurementMaintenance, - type MeasurementMaintenancePayload, -} from "@/lib/jobs/measurement-maintenance"; -import { - PULSE_DAY_MEAN_QUEUE, - handlePulseDayMean, - type PulseDayMeanPayload, -} from "@/lib/jobs/pulse-day-mean"; -import { - HEALTH_CONNECT_IMPORT_QUEUE, - HEALTH_CONNECT_IMPORT_CONCURRENCY, - handleHealthConnectImport, - type HealthConnectImportPayload, -} from "@/lib/jobs/health-connect-import-worker"; -import { - MEDICATION_INTAKE_IMPORT_QUEUE, - MEDICATION_INTAKE_IMPORT_CONCURRENCY, - handleMedicationIntakeImport, - type MedicationIntakeImportQueuePayload, -} from "@/lib/jobs/medication-intake-import"; -import { - INTAKE_SLOT_DEDUP_QUEUE, - INTAKE_SLOT_DEDUP_CONCURRENCY, - dedupeUserIntakeSlots, - enqueueBootTimeIntakeSlotDedup, - type IntakeSlotDedupPayload, -} from "@/lib/medications/intake-slot-dedup"; -import { - handleOffhostPurge, - OFFHOST_PURGE_CRON, - OFFHOST_PURGE_QUEUE, -} from "@/lib/jobs/offhost-purge"; -import { - handleRestoreDrill, - RESTORE_DRILL_CRON, - RESTORE_DRILL_QUEUE, - RESTORE_DRILL_SEND_OPTIONS, -} from "@/lib/jobs/restore-drill"; -import { - BACKUP_RESTORE_QUEUE, - enqueueBackupRestoreSweepAtBoot, - handleBackupRestore, - type BackupRestorePayload, -} from "@/lib/jobs/backup-restore"; -import { - NOTE_ENCRYPTION_BACKFILL_QUEUE, - NOTE_ENCRYPTION_BACKFILL_CONCURRENCY, - runNoteEncryptionBackfillForUser, - enqueueBootTimeNoteEncryptionBackfill, - type NoteEncryptionBackfillPayload, -} from "@/lib/jobs/note-encryption-backfill"; -import { - MED_NOTES_ENCRYPTION_BACKFILL_QUEUE, - MED_NOTES_ENCRYPTION_BACKFILL_CONCURRENCY, - runMedNotesEncryptionBackfillForUser, - enqueueBootTimeMedNotesEncryptionBackfill, - type MedNotesEncryptionBackfillPayload, -} from "@/lib/jobs/med-notes-encryption-backfill"; -import { - FREE_TEXT_ENCRYPTION_BACKFILL_QUEUE, - FREE_TEXT_ENCRYPTION_BACKFILL_CONCURRENCY, - runFreeTextEncryptionBackfillForUser, - enqueueBootTimeFreeTextEncryptionBackfill, - type FreeTextEncryptionBackfillPayload, -} from "@/lib/jobs/free-text-encryption-backfill"; -import { - CONTENT_INDEX_BACKFILL_QUEUE, - CONTENT_INDEX_BACKFILL_CONCURRENCY, - runContentIndexBackfillForUser, - type ContentIndexBackfillPayload, -} from "@/lib/jobs/document-content-index-backfill"; -import { - DOCUMENT_INDEX_QUEUE, - DOCUMENT_INDEX_CONCURRENCY, - runDocumentIndex, - type DocumentIndexPayload, -} from "@/lib/jobs/document-index"; -import { - DOCUMENT_AI_RUN_QUEUE, - DOCUMENT_AI_RUN_CONCURRENCY, - handleDocumentAiRunJobs, - type DocumentAiRunPayload, -} from "@/lib/jobs/document-ai-run"; -import { - DOCUMENT_AI_RUN_REAPER_QUEUE, - DOCUMENT_AI_RUN_REAPER_CRON, - handleDocumentAiRunReaper, - type DocumentAiRunReaperPayload, -} from "@/lib/jobs/document-ai-run-reaper"; -import { - DOCUMENT_THUMBNAIL_QUEUE, - DOCUMENT_THUMBNAIL_CONCURRENCY, - runDocumentThumbnail, - type DocumentThumbnailPayload, -} from "@/lib/jobs/document-thumbnail"; -import { - DOCUMENT_SUMMARY_QUEUE, - DOCUMENT_SUMMARY_CONCURRENCY, - runDocumentSummaryJob, - type DocumentSummaryPayload, -} from "@/lib/jobs/document-summary"; -import { - DOCUMENT_SUMMARY_CATCHUP_QUEUE, - DOCUMENT_SUMMARY_CATCHUP_CONCURRENCY, - runSummaryCatchUpForUser, - type SummaryCatchUpPayload, -} from "@/lib/jobs/document-summary-catchup"; -import { - DOCUMENT_THUMBNAIL_BACKFILL_QUEUE, - DOCUMENT_THUMBNAIL_BACKFILL_CONCURRENCY, - runThumbnailBackfillForUser, - enqueueBootTimeThumbnailBackfill, - type ThumbnailBackfillPayload, -} from "@/lib/jobs/document-thumbnail-backfill"; -import { - ENCRYPTION_KEY_ROTATE_QUEUE, - ENCRYPTION_KEY_ROTATE_CONCURRENCY, - handleEncryptionKeyRotate, - type EncryptionKeyRotatePayload, -} from "@/lib/jobs/encryption-key-rotate"; -import { - ENVIRONMENT_FETCH_QUEUE, - ENVIRONMENT_FETCH_CRON, - ENVIRONMENT_FETCH_CONCURRENCY, - handleEnvironmentFetch, - type EnvironmentFetchPayload, -} from "@/lib/jobs/environment-fetch"; -import { - DAWARICH_SYNC_CRON, - DAWARICH_SYNC_QUEUE, - handleDawarichSync, - type DawarichSyncPayload, -} from "@/lib/jobs/dawarich-sync"; -import { - ENVIRONMENT_AQ_HISTORY_CRON, - ENVIRONMENT_AQ_HISTORY_QUEUE, - enqueueBootTimeAirQualityHistory, - handleEnvironmentAqHistory, - type EnvironmentAqHistoryPayload, -} from "@/lib/jobs/environment-air-quality-history"; -import { recordError } from "@/lib/jobs/worker-status"; -import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome"; -import { - DATA_BACKUP_QUEUE, - DATA_BACKUP_SEND_OPTIONS, -} from "@/lib/jobs/data-backup-policy"; -import { workerLog, BOOT_BACKFILL_STAGGER_SECONDS } from "./shared"; -import { - createAndSchedule, - createAndWork, - cronIsTheRetry, - type QueuePolicyTable, - type ScheduleEntry, -} from "./registrar-shared"; -import { - DataBackupPayload, - OffhostBackupPayload, - handleOffhostBackup, - handleDataBackup, -} from "./backup-handlers"; -import { - RateLimitCleanupPayload, - StepUpElevationCleanupPayload, - IdempotencyCleanupPayload, - AuditLogCleanupPayload, - MoodReminderCleanupPayload, - handleMoodReminderCleanup, - PushAttemptCleanupPayload, - handlePushAttemptCleanup, - ArrivalReactionCleanupPayload, - handleArrivalReactionCleanup, - WorkoutInsightClaimCleanupPayload, - handleWorkoutInsightClaimCleanup, - MeasurementTombstoneCleanupPayload, - handleMeasurementTombstoneCleanup, - handleRateLimitCleanup, - handleIdempotencyCleanup, - handleAuditLogCleanup, - handleStepUpElevationCleanup, - CoachMessageCleanupPayload, - handleCoachMessageCleanup, - McpTokenCleanupPayload, - handleMcpTokenCleanup, -} from "./cleanup-handlers"; -import { - HostMetricSamplePayload, - FeedbackAggregatorPayload, - GeoBackfillPayload, - TlsPinMonitorPayload, - handleHostMetricSample, - handleFeedbackAggregator, - handleGeoBackfill, - handleGeolite2Fetch, - handleTlsPinMonitor, - handlePrDetection, -} from "./ops-handlers"; -import { - handleMedicationInventoryExpire, - handleIntakeAutoSkip, - handleIntakeAutoTake, -} from "./medication-maintenance"; -import { - DOCUMENT_PURGE_QUEUE, - DOCUMENT_PURGE_CRON, - handleDocumentPurge, - type DocumentPurgePayload, -} from "@/lib/jobs/document-purge"; -import { - SOFT_DELETE_PURGE_QUEUE, - SOFT_DELETE_PURGE_CRON, - handleSoftDeletePurge, - type SoftDeletePurgePayload, -} from "@/lib/jobs/soft-delete-purge"; -import { - AUTH_LEDGER_CLEANUP_QUEUE, - AUTH_LEDGER_CLEANUP_CRON, - handleAuthLedgerCleanup, - type AuthLedgerCleanupPayload, -} from "@/lib/jobs/auth-ledger-cleanup"; -import { - DOCUMENT_SUMMARY_REAPER_QUEUE, - DOCUMENT_SUMMARY_REAPER_CRON, - handleDocumentSummaryReaper, - type DocumentSummaryReaperPayload, -} from "@/lib/jobs/document-summary-reaper"; -import { - CYCLE_PREDICTION_REFRESH_QUEUE, - CYCLE_PREDICTION_REFRESH_CRON, - handleCyclePredictionRefresh, - type CyclePredictionRefreshPayload, -} from "@/lib/jobs/cycle-prediction-refresh"; -import { - MOOD_PROGNOSIS_REFRESH_QUEUE, - MOOD_PROGNOSIS_REFRESH_CRON, - handleMoodPrognosisRefresh, - type MoodPrognosisRefreshPayload, -} from "@/lib/jobs/mood-prognosis-refresh"; -import { - ACHIEVEMENT_UNLOCK_SWEEP_QUEUE, - ACHIEVEMENT_UNLOCK_SWEEP_CRON, - handleAchievementUnlockSweep, - type AchievementUnlockSweepPayload, -} from "@/lib/jobs/achievement-unlock-sweep"; - -const DATA_BACKUP_CRON = "0 3 * * 0"; // weekly Sunday at 03:00 - -const RATE_LIMIT_CLEANUP_QUEUE = "rate-limit-cleanup"; - -const RATE_LIMIT_CLEANUP_CRON = "*/5 * * * *"; // every 5 minutes - -const IDEMPOTENCY_CLEANUP_QUEUE = "idempotency-cleanup"; - -const IDEMPOTENCY_CLEANUP_CRON = "0 3 * * *"; // daily at 03:00 (Europe/Berlin) - -const STEP_UP_ELEVATION_CLEANUP_QUEUE = "step-up-elevation-cleanup"; - -// Every 15 minutes. An elevation lives five, so nothing lingers long; the -// cadence only has to be short enough that the table stays small. -const STEP_UP_ELEVATION_CLEANUP_CRON = "*/15 * * * *"; - -const AUDIT_LOG_CLEANUP_QUEUE = "audit-log-cleanup"; - -const AUDIT_LOG_CLEANUP_CRON = "15 3 * * *"; // daily at 03:15 (Europe/Berlin) -// v1.15.19 — daily duplicate dose-slot dedup discovery tick. The boot-time -// pass only ran on worker restart, so a cross-source duplicate slot created -// between deploys (a pending REMINDER row plus a standalone API/WEB row on -// the same instant) survived until the next reboot. The cron payload omits -// `userId`; the handler treats that as the discovery tick and fans out one -// per-user job (singletonKey-coalesced) exactly like the boot pass. Slots at -// 03:28 between the mood-reminder cleanup (03:25) and the inventory expire -// (03:30), inside the maintenance window. - -const INTAKE_SLOT_DEDUP_CRON = "28 3 * * *"; - -// 02:30 Europe/Berlin — runs after audit-log/idempotency cleanups so old -// rows are gone before they're snapshotted, but before the existing -// in-DB DATA_BACKUP at 03:00 (Sundays only) so the off-host copy is -// always at-or-ahead of the local one. - -const OFFHOST_BACKUP_CRON = "30 2 * * *"; - -const HOST_METRIC_QUEUE = "host-metric-sample"; -// v1.42 — every five minutes. A per-minute sample filled pg-boss's job -// table with one row a minute for a chart nobody reads at that resolution. -// The admin chart's polling interval (`host-metrics-chart.tsx`) has to match. - -const HOST_METRIC_CRON = "*/5 * * * *"; -// v1.4.16 — daily rec-feedback aggregator. 04:00 Europe/Berlin -// runs the slot AFTER all the cleanup jobs (rate-limit, idempotency, -// audit-log) so the previous-day's noise is gone before we aggregate. - -const FEEDBACK_AGGREGATOR_QUEUE = "feedback-aggregator"; - -const FEEDBACK_AGGREGATOR_CRON = "0 4 * * *"; -// v0.5.4 ios-coord — daily mood-reminder dispatch-ledger retention sweep. -// Rows older than 90 days are behavioural footprints of mood-log gaps; we -// keep them long enough to debug a duplicate-push report (~one billing -// cycle) but no longer. Slots between the audit-log cleanup (03:15) and the -// drain (03:45). - -const MOOD_REMINDER_CLEANUP_QUEUE = "mood-reminder-cleanup"; - -const MOOD_REMINDER_CLEANUP_CRON = "25 3 * * *"; - -const PUSH_ATTEMPT_CLEANUP_QUEUE = "push-attempt-cleanup"; - -const PUSH_ATTEMPT_CLEANUP_CRON = "35 3 * * *"; - -// v1.31.0 — daily prune for the data-arrival spine's reaction markers. -// Slots at 04:10, after the 03:xx retention block and the 04:00 MCP-token -// prune, so the maintenance window stays in a readable order. -const ARRIVAL_REACTION_CLEANUP_QUEUE = "arrival-reaction-cleanup"; - -const ARRIVAL_REACTION_CLEANUP_CRON = "10 4 * * *"; - -const MEASUREMENT_TOMBSTONE_CLEANUP_QUEUE = "measurement-tombstone-cleanup"; - -const MEASUREMENT_TOMBSTONE_CLEANUP_CRON = "40 3 * * *"; -// v1.18.7 — daily prune for the encrypted Coach conversation history. -// coach_messages is append-only and grows forever; rows older than the -// configurable retention window (default 365 days) are hard-deleted. -// Slots at 03:50 Europe/Berlin after the measurement-tombstone cleanup -// (03:40) and the cumulative drain (03:45), staying inside the existing -// 03:xx maintenance window. - -const COACH_MESSAGE_CLEANUP_QUEUE = "coach-message-cleanup"; - -const COACH_MESSAGE_CLEANUP_CRON = "50 3 * * *"; -// v1.40 — daily prune of the workout-insight claim ledger. Claims outlive a -// deleted workout so the delete cannot free a daily slot; rows older than a -// week are never read by the cap again. - -const WORKOUT_INSIGHT_CLAIM_CLEANUP_QUEUE = "workout-insight-claim-cleanup"; - -const WORKOUT_INSIGHT_CLAIM_CLEANUP_CRON = "52 3 * * *"; -// v1.23 — converging backfill that migrates the free-text health-note columns -// (mood + measurement) from plaintext to AES-256-GCM at rest. Boot discovery -// enqueues one per-user job; a daily 03:55 Europe/Berlin discovery tick (empty -// payload) re-fans for any rows that landed between worker reboots, so the -// migration converges without a restart. Idempotent + fail-closed per row. - -const NOTE_ENCRYPTION_BACKFILL_CRON = "55 3 * * *"; -// v1.24 — daily prune of expired/revoked MCP connector access tokens (every -// code exchange + hourly refresh mints a 60-minute row) and long-revoked OAuth -// connection anchors. Slots at 04:00 Europe/Berlin after the note-encryption -// backfill discovery (03:55) so the two don't pile up on the same boss poll. - -const MCP_TOKEN_CLEANUP_QUEUE = "mcp-token-cleanup"; - -const MCP_TOKEN_CLEANUP_CRON = "0 4 * * *"; -// v1.25 — converging backfill that migrates the three medication free-text -// note columns (side-effect note, dose-change note, inventory-item note) from -// plaintext to AES-256-GCM at rest. Boot discovery enqueues one per-user job; -// a daily 04:05 Europe/Berlin discovery tick (empty payload) re-fans for any -// rows that landed between worker reboots. Idempotent + fail-closed per row. -// Slots after the MCP-token prune (04:00) so the two don't pile up on the same -// boss poll. - -const MED_NOTES_ENCRYPTION_BACKFILL_CRON = "5 4 * * *"; -// v1.39.3 — converging backfill that moves the Coach conversation title and the -// custom-metric reading note to AES-256-GCM at rest. Boot discovery (staggered -// past the startup storm) enqueues one per-user job; a daily 04:07 -// Europe/Berlin discovery tick re-fans for rows written by a previous-release -// process after the upgrade. Two minutes after the medication-note tick so the -// two do not share a poll. v1.39.4 adds the practitioner phone + address and -// the workout GPS track to the same pass. -const FREE_TEXT_ENCRYPTION_BACKFILL_CRON = "7 4 * * *"; - -const allQueues = [ - // v1.25 — nightly environmental-context fetch. A daily discovery tick - // fans out one per-user job per opted-in account with a home location; the - // queue also serves the on-demand backfill from the Environment settings - // surface. Without this entry pg-boss never provisions it and both the cron - // and the backfill button silently no-op. - ENVIRONMENT_FETCH_QUEUE, - // v1.42 — the air-quality history backfill: per account, from boot and - // nightly discovery, the home being set and air quality switched on. - ENVIRONMENT_AQ_HISTORY_QUEUE, - // v1.43 — the Dawarich movement context: nightly discovery, per-account - // runs, and the connect / settings backfills. - DAWARICH_SYNC_QUEUE, - DATA_BACKUP_QUEUE, - RATE_LIMIT_CLEANUP_QUEUE, - IDEMPOTENCY_CLEANUP_QUEUE, - AUDIT_LOG_CLEANUP_QUEUE, - STEP_UP_ELEVATION_CLEANUP_QUEUE, - OFFHOST_BACKUP_QUEUE, - RESTORE_DRILL_QUEUE, - // v1.39.3 — deleting an account or wiping its data takes its off-host - // copies out of the bucket. Without this entry the requests would be - // written and never worked. - OFFHOST_PURGE_QUEUE, - // v1.39.1 — an admin's restore of a stored backup, run off the request, and - // the boot sweep that re-queues one a stopped worker left running. Without - // this entry every restore request would queue a job nobody works. - BACKUP_RESTORE_QUEUE, - HOST_METRIC_QUEUE, - FEEDBACK_AGGREGATOR_QUEUE, - GEO_BACKFILL_QUEUE, - // v1.37.7 (issue #659) — runtime GeoLite2 fetch. A self-hoster who sets - // MAXMIND_LICENSE_KEY on the published image gets the offline geo tier - // without a rebuild or a manual mount: boot discovery fetches when no - // database is present, a monthly cron keeps a keyed host current. Without - // this entry pg-boss never provisions the queue and both silently no-op. - GEOLITE2_FETCH_QUEUE, - // v1.12.2 ios-coord — TLS leaf SPKI-change monitor. The iOS client - // pins the served leaf certificate; this queue probes it every 6 h and - // alarms when the served SPKI leaves the operator's known-good set. - TLS_PIN_MONITOR_QUEUE, - PR_DETECTION_QUEUE, - MEDICATION_INVENTORY_EXPIRE_QUEUE, - // v1.4.46 — hourly auto-skip pass for stale unmarked intakes. Without - // this entry the schedule silently no-ops and pending rows older than - // 24 h pile up unflipped. - INTAKE_AUTO_SKIP_QUEUE, - // v1.42.2 — records the doses of a medication with auto-mark-taken on. - // Without this entry the schedule silently no-ops and those doses stay - // open. - INTAKE_AUTO_TAKE_QUEUE, - APPLE_HEALTH_IMPORT_V2_QUEUE, - APPLE_HEALTH_IMPORT_LEGACY_QUEUE, - // v1.32.1 (issue #588) — periodic sweep for ImportJob rows orphaned by a - // worker crash/restart mid-run. Without this entry the 15-minute cron - // below silently no-ops and a stuck "unpacking"/"parsing"/"upserting" row - // that survives past the next worker boot is never revisited. - IMPORT_JOB_RECONCILE_QUEUE, - MEDICATION_INTAKE_IMPORT_QUEUE, - // v1.42 (#972) — Android Health Connect export import, the Apple Health - // import's sibling. Without this entry every upload would queue a job - // nobody works. - HEALTH_CONNECT_IMPORT_QUEUE, - // v1.42 — backlog purge of compaction tombstones, queued once at boot. - // Without this entry the boot enqueue silently no-ops. - COMPACTION_TOMBSTONE_PURGE_QUEUE, - // v1.42 — one-time repair of the means older releases folded from part of - // their window; the purge above waits for it. Without this entry the boot - // enqueue silently no-ops and the purge never runs. - MEASUREMENT_FOLD_REPAIR_QUEUE, - // v1.42 — VACUUM / REINDEX of `measurements`: the admin route enqueues the - // operator's run, and a nightly check vacuums when dead rows pile up. - // Without this entry both silently no-op. - MEASUREMENT_MAINTENANCE_QUEUE, - // v1.43 — the pulse day-mean dry run and rebuild, admin-triggered only. - PULSE_DAY_MEAN_QUEUE, - // v1.8.2 — one-time duplicate dose-slot cleanup. Boot discovery enqueues - // one job per user holding two live intake rows that snap to the same - // canonical slot (the pre-fix REMINDER-pending + API-taken pair). Also - // carries a daily discovery cron (03:28). Without this entry pg-boss never - // provisions it and both the boot enqueue and the cron silently no-op. - INTAKE_SLOT_DEDUP_QUEUE, - // v0.5.4 ios-coord — mood-reminder dispatch-ledger retention sweep. - MOOD_REMINDER_CLEANUP_QUEUE, - // v1.4.49 — push-attempt ledger cleanup. The daily schedule below would - // silently no-op without this entry. - PUSH_ATTEMPT_CLEANUP_QUEUE, - // v1.31.0 — data-arrival reaction-marker prune. Without this entry the - // daily schedule silently no-ops and the markers accumulate forever. - ARRIVAL_REACTION_CLEANUP_QUEUE, - // v1.35.3 — nightly refresh of the cycle forecast rows the reminder cron - // scans. Without this entry the schedule silently no-ops and the forecast - // is only as fresh as the last time somebody opened the calendar. - CYCLE_PREDICTION_REFRESH_QUEUE, - // v1.38 — nightly rebuild of the mood forecast rows. Without this entry the - // schedule silently no-ops: nothing else writes them, so the surface would - // answer "not enough data yet" forever on an account that has plenty. - MOOD_PROGNOSIS_REFRESH_QUEUE, - // v1.35.3 — nightly pin for newly unlocked achievements. Without this entry - // the schedule silently no-ops and no unlock date is ever made durable. - ACHIEVEMENT_UNLOCK_SWEEP_QUEUE, - // v1.7.0 — soft-deleted measurement tombstone prune. Without this entry - // the daily schedule silently no-ops and pruned-past-retention tombstones - // pile up forever. - MEASUREMENT_TOMBSTONE_CLEANUP_QUEUE, - // v1.18.7 — Coach conversation-history retention prune. Without this entry - // the daily schedule silently no-ops and the encrypted coach_messages - // table grows unbounded. - COACH_MESSAGE_CLEANUP_QUEUE, - // v1.40 — workout-insight claim ledger retention prune. Without this entry - // the daily schedule silently no-ops and the claim table keeps every row - // whose workout was deleted. - WORKOUT_INSIGHT_CLAIM_CLEANUP_QUEUE, - // v1.23 — free-text health-note encryption backfill. Boot discovery + a - // daily discovery cron enqueue one per-user job per account still holding a - // plaintext note; without this entry pg-boss never provisions the queue and - // both the boot enqueue and the cron silently no-op. - NOTE_ENCRYPTION_BACKFILL_QUEUE, - // v1.23 — admin-triggered encryption-key rotation. On-demand only (no - // cron): the admin panel enqueues a singleton run that re-encrypts the - // corpus to the active key. Without this entry pg-boss never provisions the - // queue and the admin trigger silently no-ops. - ENCRYPTION_KEY_ROTATE_QUEUE, - // v1.24 — expired/revoked MCP access-token + connection prune. Without this - // entry the daily schedule silently no-ops and the api_tokens table grows - // unbounded with dead 60-minute connector rows. - MCP_TOKEN_CLEANUP_QUEUE, - // v1.25 — medication free-text note encryption backfill. Boot discovery + a - // daily discovery cron enqueue one per-user job per account still holding a - // plaintext medication note; without this entry pg-boss never provisions the - // queue and both the boot enqueue and the cron silently no-op. - MED_NOTES_ENCRYPTION_BACKFILL_QUEUE, - // v1.39.3 — Coach title + custom-metric note encryption backfill. Without - // this entry pg-boss never provisions the queue and both the boot enqueue - // and the cron silently no-op. - FREE_TEXT_ENCRYPTION_BACKFILL_QUEUE, - // Document vault — daily physical purge for tombstones past the 30-day - // undo grace (returns the encrypted blob's TOAST space). Without this entry - // the daily schedule silently no-ops and "deleted" documents hold backup - // weight forever. - DOCUMENT_PURGE_QUEUE, - // Nightly purge of expired tombstones for every soft-delete model the - // retention register hands to it (`soft-delete-retention.ts`). - SOFT_DELETE_PURGE_QUEUE, - // Nightly removal of expired sessions, tokens, challenges and trusted - // devices. - AUTH_LEDGER_CLEANUP_QUEUE, - // Document vault — hourly reaper for summary rows stuck on PENDING (a - // job dead past its retry budget). Without this entry the schedule - // silently no-ops and a dead "wird generiert" claim never converges to - // the honest UNAVAILABLE state. - DOCUMENT_SUMMARY_REAPER_QUEUE, - // Document vault P2 — on-demand content-search index backfill. Fired by the - // "index all documents" action (no cron): indexes a user's not-yet-indexed - // documents via one provider transcription each, consent + budget gated. - // Without this entry pg-boss never provisions the queue and the trigger - // silently no-ops. - CONTENT_INDEX_BACKFILL_QUEUE, - // Document AI — automatic per-document content indexing, enqueued on upload. - // Provider-first (vision) when configured + consented, else local text-layer - // extraction. Without this entry pg-boss never provisions the queue and every - // upload enqueue silently no-ops. - DOCUMENT_INDEX_QUEUE, - // v1.40 — background document AI runs ("Read with AI", lab scans): the - // request answers 202 and this queue does the read. Without this entry - // pg-boss never provisions the queue and every run waits until the reaper - // fails it as worker-unavailable. - DOCUMENT_AI_RUN_QUEUE, - // v1.40 — five-minute reaper for those runs: fails a run no worker took or - // one past its deadline, deletes a finished one after an hour. - DOCUMENT_AI_RUN_REAPER_QUEUE, - // Document vault — automatic per-document preview thumbnail, enqueued on - // upload. Pure local compute (canvas/pdfjs downscale), no egress. Without - // this entry pg-boss never provisions the queue and every upload enqueue - // silently no-ops. - DOCUMENT_THUMBNAIL_QUEUE, - // Document vault — boot-time preview-thumbnail backfill. Discovers accounts - // holding thumbnailable documents without a preview and fans out per-document - // thumbnail jobs. Without this entry pg-boss never provisions the queue and - // the boot discovery silently no-ops. - DOCUMENT_THUMBNAIL_BACKFILL_QUEUE, - // Document AI — automatic per-document plain-language summary, enqueued on - // upload. Provider (vision) only, gated on the `documentsAutoAiRead` opt-in + - // egress consent + budget; persists the summary encrypted. Without this entry - // pg-boss never provisions the queue and every upload enqueue silently no-ops. - DOCUMENT_SUMMARY_QUEUE, - // v1.30.31 — auto-read catch-up. Scheduled on a genuine OFF→ON flip of the - // `documentsAutoAiRead` opt-in; fans out summary jobs for documents that were - // uploaded while the flag was still OFF. Without this entry pg-boss never - // provisions the queue and the catch-up enqueue silently no-ops. - DOCUMENT_SUMMARY_CATCHUP_QUEUE, -]; - -// `cronIsTheRetry` (retryLimit 0) marks the passes whose handler now returns -// `jobFailed` where it used to warn and carry on, AND whose failure repeats: -// a bulk DELETE or UPDATE that timed out will time out again on an immediate -// retry, so pg-boss's default two would run the same doomed statement three -// times a night. The queues without it are the ones whose failure is -// transient — the TLS probe, the off-host upload, the per-user environment -// fetch — and those still want the default retries. The restore drill sends -// `retryLimit: 0` in its own options for the other reason: it pages each -// failed account itself, and a retry would page the same accounts again. -const schedules: ScheduleEntry[] = [ - // v1.43.7 — nightly 03:40 check of `measurements`: a plain VACUUM when the - // dead rows pass the threshold, never a rebuild. Same singleton key as the - // operator's run, so the two collapse. - [ - MEASUREMENT_MAINTENANCE_QUEUE, - MEASUREMENT_MAINTENANCE_CRON, - { ...MEASUREMENT_MAINTENANCE_SEND_OPTIONS }, - ], - // v1.25 — daily 02:10 Europe/Berlin discovery tick (empty payload) - // that fans out one per-user environment fetch per opted-in account. - [ENVIRONMENT_FETCH_QUEUE, ENVIRONMENT_FETCH_CRON], - // v1.42 — daily 02:40 discovery for the air-quality history backfill, after - // the night's environment fetch has taken its share of the budget. - [ENVIRONMENT_AQ_HISTORY_QUEUE, ENVIRONMENT_AQ_HISTORY_CRON], - // v1.43 — daily 03:20 discovery for the Dawarich movement context. A - // failed run is an outage at the instance; the default retries apply. - [DAWARICH_SYNC_QUEUE, DAWARICH_SYNC_CRON], - // The send options are the point of this tuple: without them the weekly - // snapshot inherits pg-boss's 15-minute expiration and is killed mid-run on - // any sizeable record. See `data-backup-policy.ts` for why the window rides - // the schedule rather than the queue. - [DATA_BACKUP_QUEUE, DATA_BACKUP_CRON, DATA_BACKUP_SEND_OPTIONS], - [RATE_LIMIT_CLEANUP_QUEUE, RATE_LIMIT_CLEANUP_CRON, cronIsTheRetry], - [IDEMPOTENCY_CLEANUP_QUEUE, IDEMPOTENCY_CLEANUP_CRON, cronIsTheRetry], - [AUDIT_LOG_CLEANUP_QUEUE, AUDIT_LOG_CLEANUP_CRON], - [ - STEP_UP_ELEVATION_CLEANUP_QUEUE, - STEP_UP_ELEVATION_CLEANUP_CRON, - cronIsTheRetry, - ], - [OFFHOST_BACKUP_QUEUE, OFFHOST_BACKUP_CRON, OFFHOST_BACKUP_SEND_OPTIONS], - [RESTORE_DRILL_QUEUE, RESTORE_DRILL_CRON, RESTORE_DRILL_SEND_OPTIONS], - // Nightly backstop for the purge the deletion kicks straight away: a - // request the bucket refused, or one written while the queue was down. - [OFFHOST_PURGE_QUEUE, OFFHOST_PURGE_CRON, cronIsTheRetry], - [HOST_METRIC_QUEUE, HOST_METRIC_CRON, cronIsTheRetry], - [FEEDBACK_AGGREGATOR_QUEUE, FEEDBACK_AGGREGATOR_CRON, cronIsTheRetry], - // v1.4.37 — hourly geo backfill. The helper is idempotent + capped - // at 5 000 rows per pass; running it at :40 every hour catches the - // long tail of audit rows that landed with the offline MMDB - // missing or the online provider unreachable. - [GEO_BACKFILL_QUEUE, GEO_BACKFILL_CRON, cronIsTheRetry], - // v1.37.7 (issue #659) — monthly runtime GeoLite2 refresh (8th, 04:00 - // Europe/Berlin, past MaxMind's first-Tuesday reissue). Deliberately NOT - // cronIsTheRetry: a month between ticks is far too long to be the retry, so - // the queue keeps pg-boss's default retries for a transient MaxMind hiccup. - [GEOLITE2_FETCH_QUEUE, GEOLITE2_FETCH_CRON], - // v1.12.2 ios-coord — every-6-hour TLS leaf SPKI probe (:07 off the - // hourly sync crons). Surfaces a pinned-leaf rotation well inside the - // ≥11-day re-pin window the iOS release owner needs. - [TLS_PIN_MONITOR_QUEUE, TLS_PIN_MONITOR_CRON], - // Fallback rescan every 30 minutes — protects against ingest paths - // that ship measurements without enqueueing a per-user job. The - // cron payload deliberately omits a `userId` so the handler iterates - // every user; per-user push-suppression cannot apply on the cron - // path (the silent flag is set by the ingest hooks). - [ - PR_DETECTION_QUEUE, - PR_DETECTION_FALLBACK_CRON, - { expireInSeconds: PR_DETECTION_EXPIRE_SECONDS }, - ], - // v1.4.25 — daily expire-stale pass for the per-pen inventory - // entities. Flips IN_USE rows whose 30-day clock has blown to - // EXPIRED at 03:30 Europe/Berlin (in the existing 02:xx–03:xx - // maintenance window, right after idempotency-cleanup). - [ - MEDICATION_INVENTORY_EXPIRE_QUEUE, - MEDICATION_INVENTORY_EXPIRE_CRON, - cronIsTheRetry, - ], - // v1.4.46 — hourly auto-skip for medication intakes the user never - // marked. Cron `5 * * * *` slots off the top-of-the-hour - // reminder-check (:00) so the hourly ticks don't pile up on the same - // boss poll. - [INTAKE_AUTO_SKIP_QUEUE, INTAKE_AUTO_SKIP_CRON, cronIsTheRetry], - // v1.42.2 — every 15 minutes, off the reminder tick and the auto-miss - // pass, so a dose of an auto-mark-taken medication reads taken within - // minutes of its time. - [INTAKE_AUTO_TAKE_QUEUE, INTAKE_AUTO_TAKE_CRON, cronIsTheRetry], - // v0.5.4 ios-coord — daily mood-reminder dispatch-ledger retention sweep. - [MOOD_REMINDER_CLEANUP_QUEUE, MOOD_REMINDER_CLEANUP_CRON, cronIsTheRetry], - // v1.15.19 — daily 03:28 Europe/Berlin duplicate dose-slot dedup - // discovery. The empty cron payload (no `userId`) is the handler's - // signal to run the discovery fan-out instead of a per-user pass, so - // cross-source duplicate slots created between deploys collapse within - // a day instead of waiting for the next worker reboot. - [INTAKE_SLOT_DEDUP_QUEUE, INTAKE_SLOT_DEDUP_CRON], - // v1.4.49 — daily 03:35 Europe/Berlin prune for push_attempts. - [PUSH_ATTEMPT_CLEANUP_QUEUE, PUSH_ATTEMPT_CLEANUP_CRON, cronIsTheRetry], - // v1.31.0 — daily 04:10 Europe/Berlin prune for arrival_reactions. - [ - ARRIVAL_REACTION_CLEANUP_QUEUE, - ARRIVAL_REACTION_CLEANUP_CRON, - cronIsTheRetry, - ], - // v1.7.0 — daily 03:40 Europe/Berlin prune for expired measurement - // tombstones. - [MEASUREMENT_TOMBSTONE_CLEANUP_QUEUE, MEASUREMENT_TOMBSTONE_CLEANUP_CRON], - // v1.18.7 — daily 03:50 Europe/Berlin prune for stale Coach history. - [COACH_MESSAGE_CLEANUP_QUEUE, COACH_MESSAGE_CLEANUP_CRON, cronIsTheRetry], - // v1.40 — daily 03:52 Europe/Berlin prune for the workout-insight claims. - [ - WORKOUT_INSIGHT_CLAIM_CLEANUP_QUEUE, - WORKOUT_INSIGHT_CLAIM_CLEANUP_CRON, - cronIsTheRetry, - ], - // v1.23 — daily 03:55 Europe/Berlin note-encryption backfill discovery. - // The empty cron payload (no `userId`) is the handler's signal to fan out - // one per-user job per account still holding a plaintext note. - [NOTE_ENCRYPTION_BACKFILL_QUEUE, NOTE_ENCRYPTION_BACKFILL_CRON], - // v1.24 — daily 04:00 Europe/Berlin prune for dead MCP connector tokens. - [MCP_TOKEN_CLEANUP_QUEUE, MCP_TOKEN_CLEANUP_CRON, cronIsTheRetry], - // v1.35.3 — daily 04:20 Europe/Berlin refresh of the cycle forecast rows - // the reminder cron scans. Tomorrow's tick is the retry: the forecast moves - // on a day scale and the reminder windows are days wide. - [ - CYCLE_PREDICTION_REFRESH_QUEUE, - CYCLE_PREDICTION_REFRESH_CRON, - cronIsTheRetry, - ], - // v1.38 — daily 04:35 Europe/Berlin rebuild of the mood forecast rows. - // Tomorrow's tick is the retry: the fit moves on a day scale and a night - // late writes the same rows from the same days. - [MOOD_PROGNOSIS_REFRESH_QUEUE, MOOD_PROGNOSIS_REFRESH_CRON, cronIsTheRetry], - // v1.35.3 — daily 04:25 Europe/Berlin pin for newly unlocked achievements. - // Tomorrow's tick is the retry: the date written is the computed completion - // date, so a night late writes the identical row. - [ - ACHIEVEMENT_UNLOCK_SWEEP_QUEUE, - ACHIEVEMENT_UNLOCK_SWEEP_CRON, - cronIsTheRetry, - ], - // v1.25 — daily 04:05 Europe/Berlin medication-note encryption backfill - // discovery. The empty cron payload (no `userId`) is the handler's signal to - // fan out one per-user job per account still holding a plaintext medication - // note. - [MED_NOTES_ENCRYPTION_BACKFILL_QUEUE, MED_NOTES_ENCRYPTION_BACKFILL_CRON], - // v1.39.3 — daily 04:07 Europe/Berlin discovery for the Coach title + - // custom-metric note backfill. Empty payload = fan out per user. - [FREE_TEXT_ENCRYPTION_BACKFILL_QUEUE, FREE_TEXT_ENCRYPTION_BACKFILL_CRON], - // Document vault — daily 04:10 Europe/Berlin purge for tombstoned - // documents past the 30-day undo grace. - [DOCUMENT_PURGE_QUEUE, DOCUMENT_PURGE_CRON, cronIsTheRetry], - // Daily 03:42 Europe/Berlin, after the sync-domain tombstone cleanup. The - // next night is the retry: the purge picks up whatever a failed run left. - [SOFT_DELETE_PURGE_QUEUE, SOFT_DELETE_PURGE_CRON, cronIsTheRetry], - // Daily 03:35 Europe/Berlin. Same: the next night deletes what is left. - [AUTH_LEDGER_CLEANUP_QUEUE, AUTH_LEDGER_CLEANUP_CRON, cronIsTheRetry], - // Document vault — hourly heal of summary rows stuck on PENDING. The next - // tick is the retry: the updateMany is idempotent and the TTL predicate - // re-selects anything a failed tick left behind. - [DOCUMENT_SUMMARY_REAPER_QUEUE, DOCUMENT_SUMMARY_REAPER_CRON, cronIsTheRetry], - // v1.40 — five-minute pass over background document AI runs. The next tick - // is the retry: every write is conditional on the state it leaves. - [DOCUMENT_AI_RUN_REAPER_QUEUE, DOCUMENT_AI_RUN_REAPER_CRON, cronIsTheRetry], - // v1.32.1 (issue #588) — every-15-minute orphan-ImportJob sweep. Re-runs - // the same reconcile the boot path uses, so a stuck "unpacking" row - // whose worker crashed/restarted without the boot-time pass catching it - // (heartbeat not yet stale, pg-boss job not yet expired) still converges - // to a visible `failed` state within two ticks instead of staying stuck - // forever on an otherwise-healthy worker. - [IMPORT_JOB_RECONCILE_QUEUE, IMPORT_JOB_RECONCILE_CRON, cronIsTheRetry], -]; - -/** - * De-duplication policy per maintenance queue. - * - * Every retention / cleanup cron here (rate-limit, idempotency, audit-log, - * push-attempt, tombstone, coach-message, MCP-token, document-purge, …) is a - * keyless tick with no `singletonKey` on any send, so a policy would only - * constrain the empty key. Those are deliberately absent from this table. - * - * Two deliberate omissions worth naming, because both look like candidates: - * - * - ENVIRONMENT_FETCH_QUEUE is LEFT ALONE. It is genuinely ambiguous: the - * lookback refresh sends a per-user key, but an explicit-range backfill - * sends with NO options at all, deliberately, so that "it always runs". - * Any policy would collapse every keyless explicit-range backfill onto the - * shared empty key, so two different requested date ranges would silently - * become one. That is exactly the class of silent work-dropping a policy is - * supposed to prevent, so the queue keeps `standard`. The settings - * backfill (v1.42) is held per account by a keyed time slot - * (`singletonSeconds`), which pg-boss enforces under any policy; the - * travel-period refresh stays keyless so two periods never merge. - * - APPLE_HEALTH_IMPORT_V2_QUEUE, APPLE_HEALTH_IMPORT_LEGACY_QUEUE, - * DATA_BACKUP_QUEUE and PR_DETECTION_QUEUE send keylessly by design (each - * import / backup / detection run is a distinct unit of work). A policy - * would coalesce independent runs. - */ -const queuePolicies: QueuePolicyTable = { - // Per-user, boot- and cron-discovery driven, self-converging one-shots. The - // discovery predicate re-offers the job while the work is outstanding, so - // suppressing a duplicate of a queued OR active pass cannot lose work. - [INTAKE_SLOT_DEDUP_QUEUE]: { - policy: "exclusive", - reason: - "Per-user duplicate-slot cleanup; discovery drops the user once no colliding intake pair remains.", - }, - [NOTE_ENCRYPTION_BACKFILL_QUEUE]: { - policy: "exclusive", - reason: - "Per-user note-encryption backfill; discovery drops the user once no plaintext note remains.", - }, - [MED_NOTES_ENCRYPTION_BACKFILL_QUEUE]: { - policy: "exclusive", - reason: - "Per-user medication-note encryption backfill; discovery drops the user once no plaintext note remains.", - }, - [FREE_TEXT_ENCRYPTION_BACKFILL_QUEUE]: { - policy: "exclusive", - reason: - "Per-user encryption backfill (Coach title, custom-metric note, practitioner phone + address, workout GPS track); discovery drops the user once no readable value remains.", - }, - [DOCUMENT_THUMBNAIL_BACKFILL_QUEUE]: { - policy: "exclusive", - reason: - "Per-user thumbnail backfill; discovery drops the user once every thumbnailable document has a preview.", - }, - [CONTENT_INDEX_BACKFILL_QUEUE]: { - policy: "exclusive", - reason: - "Per-user index-all run, triggered from the documents surface. Idempotent (it indexes only not-yet-indexed documents), so a second press while one runs is correctly a no-op.", - }, - [ENCRYPTION_KEY_ROTATE_QUEUE]: { - policy: "exclusive", - reason: - "Fixed singleton key, admin-triggered. Two concurrent corpus rotations must never overlap; the route already reports a suppressed send back as alreadyQueued.", - }, - // v1.42 — VACUUM / REINDEX of `measurements`, operator or nightly. - [MEASUREMENT_MAINTENANCE_QUEUE]: { - policy: "exclusive", - reason: - "Fixed singleton key on the admin send and the nightly tick alike. Two maintenance passes would vacuum or rebuild side by side; the route reports a suppressed send back as enqueued: false.", - }, - // v1.43 — the pulse day-mean dry run and rebuild. - [PULSE_DAY_MEAN_QUEUE]: { - policy: "exclusive", - reason: - "Fixed singleton key, admin-triggered. A dry run beside a rebuild would compare against rollups being rewritten; the route answers 409 busy instead.", - }, - // v1.42 — compaction-tombstone backlog purge. `short`, not `exclusive`: - // a run that stops early sends its own follow-up while it is still active, - // and `exclusive` would swallow that send. A boot's send while a run is - // queued collapses into it; the delete predicate is idempotent, so a boot - // that lands beside an active run costs a duplicate scan, never a wrong row. - [COMPACTION_TOMBSTONE_PURGE_QUEUE]: { - policy: "short", - reason: - "Fixed singleton key; collapse queued duplicates only, so the active run can queue its own follow-up.", - }, - // v1.42 — per-account fold repair. `short` for the same reason: a run the - // budget stopped sends its own follow-up for the account while it is still - // active. A boot's send for a queued account collapses into it; a duplicate - // beside an active run rewrites nothing, since only differing means are - // written. - // v1.42 — the air-quality history backfill. `short` for the same reason: a - // run with work left sends its own follow-up while it is still active. A - // send for an account whose run is queued collapses into it; one beside an - // active run only re-reads what is done, since done days are never fetched. - [ENVIRONMENT_AQ_HISTORY_QUEUE]: { - policy: "short", - reason: - "Per-account singleton key; collapse queued duplicates only, so the active run can queue its own follow-up.", - }, - [MEASUREMENT_FOLD_REPAIR_QUEUE]: { - policy: "short", - reason: - "Per-account singleton key; collapse queued duplicates only, so the active run can queue its own follow-up.", - }, - - // Per-document, enqueued on upload. `short`, NOT `exclusive`: each handler - // re-reads the document when it starts, so collapsing sends that arrive while - // an identical job is still queued is safe. `exclusive` would additionally - // suppress a re-process requested after the current job had already read the - // old bytes, leaving a stale summary/thumbnail/index behind with no discovery - // pass to re-offer it — these queues have no cron to re-converge. - [DOCUMENT_INDEX_QUEUE]: { - policy: "short", - reason: - "Per-document, no re-converging discovery pass. Collapse queued duplicates only, so a re-index after a content change is never dropped.", - }, - [DOCUMENT_THUMBNAIL_QUEUE]: { - policy: "short", - reason: - "Per-document, no re-converging discovery pass. Collapse queued duplicates only.", - }, - [DOCUMENT_SUMMARY_QUEUE]: { - policy: "short", - reason: - "Per-document, no re-converging discovery pass. Collapse queued duplicates only.", - }, - [DOCUMENT_SUMMARY_CATCHUP_QUEUE]: { - policy: "short", - reason: - "Per-user, keyed on the opt-in flip. Collapse a double toggle into one pass; the pass re-reads its candidate set when it starts.", - }, -}; - -/** - * Register every maintenance / ops / cleanup queue. Returns the queue names - * created (for the boot-level aggregate assertion). - */ -export async function registerMaintenanceQueues( - boss: PgBoss, -): Promise { - await createAndSchedule(boss, allQueues, schedules, queuePolicies); - - await createAndWork( - boss, - DATA_BACKUP_QUEUE, - { localConcurrency: 1 }, - lockedPass( - DATA_BACKUP_QUEUE, - () => WHOLE_PASS, - heavyLane(DATA_BACKUP_QUEUE, handleDataBackup), - ), - ); - await createAndWork( - boss, - RATE_LIMIT_CLEANUP_QUEUE, - { localConcurrency: 1 }, - handleRateLimitCleanup, - ); - await createAndWork( - boss, - IDEMPOTENCY_CLEANUP_QUEUE, - { localConcurrency: 1 }, - handleIdempotencyCleanup, - ); - await createAndWork( - boss, - AUDIT_LOG_CLEANUP_QUEUE, - { localConcurrency: 1 }, - handleAuditLogCleanup, - ); - await createAndWork( - boss, - STEP_UP_ELEVATION_CLEANUP_QUEUE, - { localConcurrency: 1 }, - handleStepUpElevationCleanup, - ); - // With metadata: the handler resumes a retry from the job's creation time. - await createAndWork( - boss, - OFFHOST_BACKUP_QUEUE, - { localConcurrency: 1, includeMetadata: true }, - lockedPass( - OFFHOST_BACKUP_QUEUE, - () => WHOLE_PASS, - heavyLane(OFFHOST_BACKUP_QUEUE, handleOffhostBackup), - ), - ); - // Locked: a drill that outlives a restart or a manual run never reads the - // bucket beside another one. - await createAndWork( - boss, - RESTORE_DRILL_QUEUE, - { localConcurrency: 1 }, - lockedPass( - RESTORE_DRILL_QUEUE, - () => WHOLE_PASS, - heavyLane(RESTORE_DRILL_QUEUE, handleRestoreDrill), - ), - ); - await createAndWork( - boss, - OFFHOST_PURGE_QUEUE, - { localConcurrency: 1 }, - handleOffhostPurge, - ); - // One restore at a time per worker: each holds a transaction over a whole - // account and a batch of readings in memory, and two at once on a small host - // is how a restore that fits alone would run out of heap. - await createAndWork( - boss, - BACKUP_RESTORE_QUEUE, - { localConcurrency: 1 }, - heavyLane(BACKUP_RESTORE_QUEUE, handleBackupRestore), - ); - // A restore the previous process was running is picked up again once its - // heartbeat has gone stale; see `sweepInterruptedRestores`. - await enqueueBackupRestoreSweepAtBoot(boss); - await createAndWork( - boss, - HOST_METRIC_QUEUE, - { localConcurrency: 1 }, - handleHostMetricSample, - ); - await createAndWork( - boss, - FEEDBACK_AGGREGATOR_QUEUE, - { localConcurrency: 1 }, - handleFeedbackAggregator, - ); - await createAndWork( - boss, - GEO_BACKFILL_QUEUE, - { localConcurrency: 1 }, - handleGeoBackfill, - ); - // v1.37.7 (issue #659) — runtime GeoLite2 fetch. Single-flight: one download - // at a time, and two ticks racing the same atomic place is wasted bandwidth. - await createAndWork( - boss, - GEOLITE2_FETCH_QUEUE, - { localConcurrency: 1 }, - handleGeolite2Fetch, - ); - // v1.12.2 ios-coord — TLS leaf SPKI-change monitor. Single-flight: one - // short outbound TLS handshake per tick, no benefit to overlapping ticks. - await createAndWork( - boss, - TLS_PIN_MONITOR_QUEUE, - { localConcurrency: 1 }, - handleTlsPinMonitor, - ); - await createAndWork( - boss, - MOOD_REMINDER_CLEANUP_QUEUE, - { localConcurrency: 1 }, - handleMoodReminderCleanup, - ); - // v1.4.49 — daily prune of the push-attempt ledger. Single-flight - // matches every other cleanup queue; two ticks racing on the same - // DELETE statement is wasted work and the second tick's payload - // would be a no-op anyway. - await createAndWork( - boss, - PUSH_ATTEMPT_CLEANUP_QUEUE, - { localConcurrency: 1 }, - handlePushAttemptCleanup, - ); - // v1.31.0 — daily prune of the data-arrival reaction markers. Single-flight - // like every other cleanup queue: two ticks racing the same DELETE is - // wasted work. - await createAndWork( - boss, - ARRIVAL_REACTION_CLEANUP_QUEUE, - { localConcurrency: 1 }, - handleArrivalReactionCleanup, - ); - // v1.35.3 — nightly cycle-forecast refresh. Single-flight: two ticks - // recomputing the same cohort is wasted work and the second upsert is a - // no-op against the debounce. - await createAndWork( - boss, - CYCLE_PREDICTION_REFRESH_QUEUE, - { localConcurrency: 1 }, - handleCyclePredictionRefresh, - ); - // v1.38 — nightly mood-forecast rebuild. Single-flight: two ticks would fit - // the same windows twice and the second pass would write the identical rows. - await createAndWork( - boss, - MOOD_PROGNOSIS_REFRESH_QUEUE, - { localConcurrency: 1 }, - handleMoodPrognosisRefresh, - ); - // v1.35.3 — nightly achievement-unlock pin. Single-flight: the underlying - // createMany is idempotent on the (userId, achievementId) unique, so a - // second concurrent tick would only re-walk the same accounts. - await createAndWork( - boss, - ACHIEVEMENT_UNLOCK_SWEEP_QUEUE, - { localConcurrency: 1 }, - handleAchievementUnlockSweep, - ); - // v1.7.0 — daily prune of expired measurement tombstones. Single-flight - // like every other cleanup queue. - await createAndWork( - boss, - MEASUREMENT_TOMBSTONE_CLEANUP_QUEUE, - { localConcurrency: 1 }, - handleMeasurementTombstoneCleanup, - ); - // v1.18.7 — daily prune of the encrypted Coach conversation history. - // Single-flight like every other cleanup queue; two ticks racing on the - // same DELETE is wasted work and the second is a no-op. - await createAndWork( - boss, - COACH_MESSAGE_CLEANUP_QUEUE, - { localConcurrency: 1 }, - handleCoachMessageCleanup, - ); - // v1.40 — daily prune of the workout-insight claim ledger. Single-flight - // like every other cleanup queue. - await createAndWork( - boss, - WORKOUT_INSIGHT_CLAIM_CLEANUP_QUEUE, - { localConcurrency: 1 }, - handleWorkoutInsightClaimCleanup, - ); - // MCP Phase 3 (M2) — daily prune of dead MCP connector access tokens + - // long-revoked connection anchors. Single-flight like every other cleanup. - await createAndWork( - boss, - MCP_TOKEN_CLEANUP_QUEUE, - { localConcurrency: 1 }, - handleMcpTokenCleanup, - ); - // Document vault — daily purge of tombstoned documents past the 30-day - // undo grace. Single-flight like every other cleanup queue; the underlying - // deleteMany is idempotent so a duplicate tick is a no-op. - await createAndWork( - boss, - DOCUMENT_PURGE_QUEUE, - { localConcurrency: 1 }, - handleDocumentPurge, - ); - // Single-flight like every other cleanup queue; both deletes are bounded - // and idempotent, so a duplicate tick finds nothing. - await createAndWork( - boss, - SOFT_DELETE_PURGE_QUEUE, - { localConcurrency: 1 }, - handleSoftDeletePurge, - ); - await createAndWork( - boss, - AUTH_LEDGER_CLEANUP_QUEUE, - { localConcurrency: 1 }, - handleAuthLedgerCleanup, - ); - // v1.40 — background document AI runs. Each job is one run, under the lock - // `lockedPass` takes per run id, so a redelivery never reads beside the - // first delivery; the claim inside is conditional as well. - await createAndWork( - boss, - DOCUMENT_AI_RUN_QUEUE, - { localConcurrency: DOCUMENT_AI_RUN_CONCURRENCY }, - lockedPass( - DOCUMENT_AI_RUN_QUEUE, - (job) => `run:${job.data.runId ?? ""}`, - handleDocumentAiRunJobs, - ), - ); - // v1.40 — the five-minute run reaper. Single-flight; every write in it is - // conditional, so a duplicate tick is a no-op. - await createAndWork( - boss, - DOCUMENT_AI_RUN_REAPER_QUEUE, - { localConcurrency: 1 }, - handleDocumentAiRunReaper, - ); - // Document vault — hourly stale-PENDING summary reaper. Single-flight; - // the underlying updateMany is idempotent so a duplicate tick is a no-op. - await createAndWork( - boss, - DOCUMENT_SUMMARY_REAPER_QUEUE, - { localConcurrency: 1 }, - handleDocumentSummaryReaper, - ); - await createAndWork( - boss, - PR_DETECTION_QUEUE, - { localConcurrency: PR_DETECTION_CONCURRENCY }, - // An ingest job is one account; the cron pass is every account, one run - // at a time. - lockedPass( - PR_DETECTION_QUEUE, - (job) => { - const userId = (job.data as PrDetectionPayload | undefined)?.userId; - return userId ? `user:${userId}` : WHOLE_PASS; - }, - handlePrDetection, - ), - ); - await createAndWork( - boss, - MEDICATION_INVENTORY_EXPIRE_QUEUE, - { localConcurrency: 1 }, - handleMedicationInventoryExpire, - ); - // v1.4.46 — hourly auto-skip for stale unmarked intakes. - // Single-flight: two ticks racing against the same row pile is wasted - // work, and the underlying `updateMany` is the canonical idempotent - // shape anyway. - await createAndWork( - boss, - INTAKE_AUTO_SKIP_QUEUE, - { localConcurrency: 1 }, - handleIntakeAutoSkip, - ); - // v1.42.2 — auto-mark-taken pass. Single-flight: two passes racing over - // the same slots would only collide on the conditional convert. - await createAndWork( - boss, - INTAKE_AUTO_TAKE_QUEUE, - { localConcurrency: 1 }, - handleIntakeAutoTake, - ); - // v1.4.34 — Apple Health export.zip ingest worker. localConcurrency - // caps at 1 because the parse loop is CPU-bound and a concurrent - // second 1 GB import would race the first for RSS. - await createAndWork( - boss, - APPLE_HEALTH_IMPORT_V2_QUEUE, - { localConcurrency: APPLE_HEALTH_IMPORT_CONCURRENCY }, - heavyLane(APPLE_HEALTH_IMPORT_V2_QUEUE, async (jobs) => { - // pg-boss v12 work callbacks always receive an array (batched - // worker mode); for our concurrency-1 + batchSize-1 case we just - // process each job sequentially. - // - // Each import is independent work, so a failed one does not abort the - // rest of the batch; the first failure is what the job reports. - let failure: JobOutcome | null = null; - for (const job of jobs) { - const outcome = await handleAppleHealthImport(job); - if (!outcome.ok && failure === null) failure = outcome; - } - return failure ?? jobDone({ jobs: jobs.length }); - }), - ); - // Drain pre-revision-2 backlog by moving each claimed legacy job onto the - // isolated v2 queue. This preserves the ImportJob status id without letting - // the current parser execute under a revision-1 mirror. - await createAndWork( - boss, - APPLE_HEALTH_IMPORT_LEGACY_QUEUE, - { localConcurrency: APPLE_HEALTH_IMPORT_CONCURRENCY }, - async (jobs) => { - for (const job of jobs) { - await migrateLegacyAppleHealthImport(job); - } - return jobDone({ jobs: jobs.length }); - }, - ); - // v1.42 (#972) — Health Connect import. One at a time per worker, like the - // Apple Health import: each run reads a whole export and consumes its - // staged upload. - await createAndWork( - boss, - HEALTH_CONNECT_IMPORT_QUEUE, - { localConcurrency: HEALTH_CONNECT_IMPORT_CONCURRENCY }, - heavyLane(HEALTH_CONNECT_IMPORT_QUEUE, handleHealthConnectImport), - ); - // v1.42 — compaction-tombstone backlog purge. Serial: each batch holds - // an account against a concurrent restore. - await createAndWork( - boss, - COMPACTION_TOMBSTONE_PURGE_QUEUE, - { localConcurrency: 1 }, - handleCompactionTombstonePurge, - ); - // v1.42 — fold repair. Serial: each day holds the account against a - // concurrent restore, and the purge waits for it. - await createAndWork( - boss, - MEASUREMENT_FOLD_REPAIR_QUEUE, - { localConcurrency: 1 }, - handleMeasurementFoldRepair, - ); - // v1.42 — measurement table maintenance, operator-triggered only. - await createAndWork( - boss, - MEASUREMENT_MAINTENANCE_QUEUE, - { localConcurrency: 1 }, - handleMeasurementMaintenance, - ); - // v1.43 — pulse day-mean dry run and rebuild, operator-triggered only. - await createAndWork( - boss, - PULSE_DAY_MEAN_QUEUE, - { localConcurrency: 1 }, - handlePulseDayMean, - ); - // v1.32.1 (issue #588) — periodic orphan-ImportJob sweep. Single-flight: - // the underlying `updateMany` is idempotent and two ticks racing the same - // read-then-write pass would just be wasted work. - await createAndWork( - boss, - IMPORT_JOB_RECONCILE_QUEUE, - { localConcurrency: 1 }, - handleImportJobReconcileTick, - ); - await createAndWork( - boss, - MEDICATION_INTAKE_IMPORT_QUEUE, - { - localConcurrency: MEDICATION_INTAKE_IMPORT_CONCURRENCY, - includeMetadata: true, - }, - async (jobs) => { - // Independent per-delivery imports: run them all, report the first - // failure rather than losing it behind a later success. - let failure: JobOutcome | null = null; - for (const job of jobs) { - const outcome = await handleMedicationIntakeImport(job); - if (!outcome.ok && failure === null) failure = outcome; - } - return failure ?? jobDone({ jobs: jobs.length }); - }, - ); - - // v1.8.2 — duplicate dose-slot cleanup worker. The boot enqueue helper - // below sends one job per user holding two live intake rows that snap - // to the same canonical slot; this handler keeps the winner, soft- - // deletes the losers, normalises the winner's scheduledFor, and - // recomputes the affected compliance rollups. Serial concurrency so the - // pass never crowds the request pool. - // - // v1.15.19 — the queue also carries a daily cron tick (03:28, scheduled - // above) whose payload omits `userId`. That tick runs the SAME discovery - // fan-out the boot pass uses, so duplicate slots created between deploys - // collapse within a day instead of waiting for the next worker reboot. - await createAndWork>( - boss, - INTAKE_SLOT_DEDUP_QUEUE, - { localConcurrency: INTAKE_SLOT_DEDUP_CONCURRENCY }, - async (jobs) => { - let users = 0; - let discoveryEnqueued = 0; - let discoverySkipped = 0; - // The discovery helper returns its error instead of throwing, so a - // failed fan-out would otherwise leave no trace on the job at all. - let discoveryFailed = 0; - let slotsCollapsed = 0; - let rowsSoftDeleted = 0; - let rowsNormalised = 0; - let daysRecomputed = 0; - for (const job of jobs) { - const { userId } = job.data; - if (!userId) { - // Daily discovery tick — fan out one per-user job per account - // still holding duplicate-slot candidates (singletonKey-coalesced, - // identical to the boot pass). - const result = await enqueueBootTimeIntakeSlotDedup(); - discoveryEnqueued += result.enqueued; - discoverySkipped += result.skipped; - if (result.error) discoveryFailed++; - workerLog( - "info", - `[intake-slot-dedup] daily discovery enqueued=${result.enqueued} skipped=${result.skipped}${result.error ? ` error=${result.error}` : ""}`, - ); - continue; - } - try { - const summary = await dedupeUserIntakeSlots(userId); - users++; - slotsCollapsed += summary.slotsCollapsed; - rowsSoftDeleted += summary.rowsSoftDeleted; - rowsNormalised += summary.rowsNormalised; - daysRecomputed += summary.daysRecomputed; - workerLog( - "info", - `[intake-slot-dedup] user=${userId} slotsCollapsed=${summary.slotsCollapsed} rowsSoftDeleted=${summary.rowsSoftDeleted} rowsNormalised=${summary.rowsNormalised} daysRecomputed=${summary.daysRecomputed}`, - ); - } catch (err) { - recordError(); - workerLog("error", `[intake-slot-dedup] user=${userId} failed`, err); - throw err; - } - } - return jobDone({ - jobs: jobs.length, - users, - discovery_enqueued: discoveryEnqueued, - discovery_skipped: discoverySkipped, - discovery_failed: discoveryFailed, - slots_collapsed: slotsCollapsed, - rows_soft_deleted: rowsSoftDeleted, - rows_normalised: rowsNormalised, - days_recomputed: daysRecomputed, - }); - }, - ); - - // v1.23 — note-encryption backfill worker. The boot enqueue helper (and the - // daily 03:55 discovery tick) send one job per user still holding a plaintext - // note; this handler migrates that user's rows to the encrypted columns. An - // empty-userId payload is the daily discovery tick and re-fans the per-user - // jobs (singletonKey-coalesced, identical to the boot pass). Serial - // concurrency so the migration never crowds the request pool. - await createAndWork>( - boss, - NOTE_ENCRYPTION_BACKFILL_QUEUE, - { localConcurrency: NOTE_ENCRYPTION_BACKFILL_CONCURRENCY }, - async (jobs) => { - let users = 0; - let discoveryEnqueued = 0; - let discoverySkipped = 0; - // The discovery helper returns its error instead of throwing, so a - // failed fan-out would otherwise leave no trace on the job at all. - let discoveryFailed = 0; - let measurements = 0; - let moodEntries = 0; - for (const job of jobs) { - const { userId } = job.data; - if (!userId) { - const result = await enqueueBootTimeNoteEncryptionBackfill(); - discoveryEnqueued += result.enqueued; - discoverySkipped += result.skipped; - if (result.error) discoveryFailed++; - workerLog( - "info", - `[note-encryption-backfill] daily discovery enqueued=${result.enqueued} skipped=${result.skipped}${result.error ? ` error=${result.error}` : ""}`, - ); - continue; - } - try { - const { measurementsMigrated, moodEntriesMigrated } = - await runNoteEncryptionBackfillForUser(userId); - users++; - measurements += measurementsMigrated; - moodEntries += moodEntriesMigrated; - workerLog( - "info", - `[note-encryption-backfill] user=${userId} measurements=${measurementsMigrated} moodEntries=${moodEntriesMigrated}`, - ); - } catch (err) { - recordError(); - workerLog( - "error", - `[note-encryption-backfill] user=${userId} failed`, - err, - ); - throw err; - } - } - return jobDone({ - jobs: jobs.length, - users, - discovery_enqueued: discoveryEnqueued, - discovery_skipped: discoverySkipped, - discovery_failed: discoveryFailed, - measurements_migrated: measurements, - mood_entries_migrated: moodEntries, - }); - }, - ); - - // v1.23 — admin-triggered encryption-key rotation (on-demand, singleton). - await createAndWork( - boss, - ENCRYPTION_KEY_ROTATE_QUEUE, - { localConcurrency: ENCRYPTION_KEY_ROTATE_CONCURRENCY }, - lockedPass( - ENCRYPTION_KEY_ROTATE_QUEUE, - () => WHOLE_PASS, - handleEncryptionKeyRotate, - ), - ); - - // v1.25 — medication-note encryption backfill worker. The boot enqueue - // helper (and the daily 04:05 discovery tick) send one job per user still - // holding a plaintext medication note; this handler migrates that user's - // rows across all three tables to the encrypted columns. An empty-userId - // payload is the daily discovery tick and re-fans the per-user jobs - // (singletonKey-coalesced, identical to the boot pass). Serial concurrency - // so the migration never crowds the request pool. - await createAndWork>( - boss, - MED_NOTES_ENCRYPTION_BACKFILL_QUEUE, - { localConcurrency: MED_NOTES_ENCRYPTION_BACKFILL_CONCURRENCY }, - async (jobs) => { - let users = 0; - let discoveryEnqueued = 0; - let discoverySkipped = 0; - // The discovery helper returns its error instead of throwing, so a - // failed fan-out would otherwise leave no trace on the job at all. - let discoveryFailed = 0; - let sideEffects = 0; - let doseChanges = 0; - let inventoryItems = 0; - for (const job of jobs) { - const { userId } = job.data; - if (!userId) { - const result = await enqueueBootTimeMedNotesEncryptionBackfill(); - discoveryEnqueued += result.enqueued; - discoverySkipped += result.skipped; - if (result.error) discoveryFailed++; - workerLog( - "info", - `[med-notes-encryption-backfill] daily discovery enqueued=${result.enqueued} skipped=${result.skipped}${result.error ? ` error=${result.error}` : ""}`, - ); - continue; - } - try { - const { - sideEffectsMigrated, - doseChangesMigrated, - inventoryItemsMigrated, - } = await runMedNotesEncryptionBackfillForUser(userId); - users++; - sideEffects += sideEffectsMigrated; - doseChanges += doseChangesMigrated; - inventoryItems += inventoryItemsMigrated; - workerLog( - "info", - `[med-notes-encryption-backfill] user=${userId} sideEffects=${sideEffectsMigrated} doseChanges=${doseChangesMigrated} inventoryItems=${inventoryItemsMigrated}`, - ); - } catch (err) { - recordError(); - workerLog( - "error", - `[med-notes-encryption-backfill] user=${userId} failed`, - err, - ); - throw err; - } - } - return jobDone({ - jobs: jobs.length, - users, - discovery_enqueued: discoveryEnqueued, - discovery_skipped: discoverySkipped, - discovery_failed: discoveryFailed, - side_effects_migrated: sideEffects, - dose_changes_migrated: doseChanges, - inventory_items_migrated: inventoryItems, - }); - }, - ); - - // v1.39.3 — Coach title + custom-metric note encryption backfill worker. - // Same shape as the medication-note worker above: an empty-userId payload is - // the daily discovery tick, a userId payload migrates that account's rows. - await createAndWork>( - boss, - FREE_TEXT_ENCRYPTION_BACKFILL_QUEUE, - { localConcurrency: FREE_TEXT_ENCRYPTION_BACKFILL_CONCURRENCY }, - async (jobs) => { - let users = 0; - let discoveryEnqueued = 0; - let discoverySkipped = 0; - let discoveryFailed = 0; - let titles = 0; - let notes = 0; - let contacts = 0; - let routes = 0; - let appointmentAddresses = 0; - let contactAudits = 0; - for (const job of jobs) { - const { userId } = job.data; - if (!userId) { - const result = await enqueueBootTimeFreeTextEncryptionBackfill(); - discoveryEnqueued += result.enqueued; - discoverySkipped += result.skipped; - if (result.error) discoveryFailed++; - workerLog( - "info", - `[free-text-encryption-backfill] daily discovery enqueued=${result.enqueued} skipped=${result.skipped}${result.error ? ` error=${result.error}` : ""}`, - ); - continue; - } - try { - const { - conversationTitlesMigrated, - metricNotesMigrated, - practitionerContactsMigrated, - routeGeometriesMigrated, - appointmentAddressesCleared, - contactAuditRowsScrubbed, - } = await runFreeTextEncryptionBackfillForUser(userId); - users++; - titles += conversationTitlesMigrated; - notes += metricNotesMigrated; - contacts += practitionerContactsMigrated; - routes += routeGeometriesMigrated; - appointmentAddresses += appointmentAddressesCleared; - contactAudits += contactAuditRowsScrubbed; - workerLog( - "info", - `[free-text-encryption-backfill] user=${userId} titles=${conversationTitlesMigrated} notes=${metricNotesMigrated} contacts=${practitionerContactsMigrated} routes=${routeGeometriesMigrated} appointment_addresses=${appointmentAddressesCleared} contact_audits=${contactAuditRowsScrubbed}`, - ); - } catch (err) { - recordError(); - workerLog( - "error", - `[free-text-encryption-backfill] user=${userId} failed`, - err, - ); - throw err; - } - } - return jobDone({ - jobs: jobs.length, - users, - discovery_enqueued: discoveryEnqueued, - discovery_skipped: discoverySkipped, - discovery_failed: discoveryFailed, - conversation_titles_migrated: titles, - metric_notes_migrated: notes, - practitioner_contacts_migrated: contacts, - route_geometries_migrated: routes, - appointment_addresses_cleared: appointmentAddresses, - contact_audit_rows_scrubbed: contactAudits, - }); - }, - ); - - // Document vault P2 — on-demand content-search index backfill worker. The - // trigger endpoint sends one per-user job; this handler indexes that user's - // not-yet-indexed documents (consent + budget gated, bounded + resumable). - // Serial concurrency so the provider calls never crowd the request pool. - await createAndWork( - boss, - CONTENT_INDEX_BACKFILL_QUEUE, - { localConcurrency: CONTENT_INDEX_BACKFILL_CONCURRENCY }, - lockedPass( - CONTENT_INDEX_BACKFILL_QUEUE, - (job) => `user:${job.data.userId ?? ""}`, - async (jobs) => { - let users = 0; - let stoppedEarly = false; - const shouldStop = jobBudget(jobs); - let skipped = 0; - let documentsIndexed = 0; - let documentsRetokenised = 0; - let documentsSkipped = 0; - let documentsFailed = 0; - for (const job of jobs) { - const { userId } = job.data; - if (!userId) { - skipped++; - continue; - } - try { - const { - indexed, - retokenised, - skipped: docsSkipped, - failed: docsFailed, - reason, - stoppedEarly: stopped, - } = await runContentIndexBackfillForUser(userId, shouldStop); - users++; - stoppedEarly ||= stopped === true; - documentsIndexed += indexed; - documentsRetokenised += retokenised; - documentsSkipped += docsSkipped; - documentsFailed += docsFailed; - workerLog( - "info", - `[document-content-index-backfill] user=${userId} indexed=${indexed} retokenised=${retokenised} skipped=${docsSkipped} failed=${docsFailed} reason=${reason}`, - ); - } catch (err) { - recordError(); - workerLog( - "error", - `[document-content-index-backfill] user=${userId} failed`, - err, - ); - throw err; - } - } - return jobDone({ - jobs: jobs.length, - users, - skipped, - documents_indexed: documentsIndexed, - documents_retokenised: documentsRetokenised, - documents_skipped: documentsSkipped, - documents_failed: documentsFailed, - stopped_early: stoppedEarly, - }); - }, - ), - ); - - // Document AI — automatic per-document content indexing. Enqueued on upload - // (one job per stored document); provider-first (vision) when configured + - // consented, else local text-layer extraction. Serial concurrency so the - // provider calls + PDF parse never crowd the request pool. - await createAndWork( - boss, - DOCUMENT_INDEX_QUEUE, - { localConcurrency: DOCUMENT_INDEX_CONCURRENCY }, - async (jobs) => { - let documents = 0; - let skipped = 0; - for (const job of jobs) { - const { userId, documentId } = job.data; - if (!userId || !documentId) { - skipped++; - continue; - } - try { - await runDocumentIndex(job.data); - documents++; - } catch (err) { - recordError(); - workerLog( - "error", - `[document-index] user=${userId} document=${documentId} failed`, - err, - ); - throw err; - } - } - return jobDone({ jobs: jobs.length, documents, skipped }); - }, - ); - - // Document vault — automatic per-document preview thumbnail. Enqueued on - // upload (one job per stored document); decodes + downscales the original to - // a small encrypted JPEG. Pure local compute; serial concurrency so the - // canvas/pdfjs decode never crowds the request pool. - await createAndWork( - boss, - DOCUMENT_THUMBNAIL_QUEUE, - { localConcurrency: DOCUMENT_THUMBNAIL_CONCURRENCY }, - async (jobs) => { - let documents = 0; - let skipped = 0; - for (const job of jobs) { - const { userId, documentId } = job.data; - if (!userId || !documentId) { - skipped++; - continue; - } - try { - await runDocumentThumbnail(job.data); - documents++; - } catch (err) { - recordError(); - workerLog( - "error", - `[document-thumbnail] user=${userId} document=${documentId} failed`, - err, - ); - throw err; - } - } - return jobDone({ jobs: jobs.length, documents, skipped }); - }, - ); - - // Document AI — automatic per-document plain-language summary. Enqueued on - // upload (one job per stored document); runs the vision provider ONLY when - // the `documentsAutoAiRead` opt-in is ON (egress consent + budget gated) and - // persists the summary encrypted. Serial concurrency so the provider call - // never crowds the request pool. - await createAndWork( - boss, - DOCUMENT_SUMMARY_QUEUE, - { localConcurrency: DOCUMENT_SUMMARY_CONCURRENCY }, - async (jobs) => { - let documents = 0; - let skipped = 0; - for (const job of jobs) { - const { userId, documentId } = job.data; - if (!userId || !documentId) { - skipped++; - continue; - } - try { - await runDocumentSummaryJob(job.data); - documents++; - } catch (err) { - recordError(); - workerLog( - "error", - `[document-summary] user=${userId} document=${documentId} failed`, - err, - ); - throw err; - } - } - return jobDone({ jobs: jobs.length, documents, skipped }); - }, - ); - - // Document AI — auto-read catch-up worker. A genuine OFF→ON flip of the - // `documentsAutoAiRead` opt-in sends one per-user job; this handler fans out - // per-document summary jobs for that user's already-stored, un-summarised - // documents (the upload-time enqueue no-opped while the flag was OFF). It - // only enqueues — the opt-in, egress consent and budget gates all still run - // per document inside the summary job. Serial concurrency. - await createAndWork( - boss, - DOCUMENT_SUMMARY_CATCHUP_QUEUE, - { localConcurrency: DOCUMENT_SUMMARY_CATCHUP_CONCURRENCY }, - async (jobs) => { - let users = 0; - let skipped = 0; - let summariesEnqueued = 0; - for (const job of jobs) { - const { userId } = job.data; - if (!userId) { - skipped++; - continue; - } - try { - const { enqueued } = await runSummaryCatchUpForUser(userId); - users++; - summariesEnqueued += enqueued; - } catch (err) { - recordError(); - workerLog( - "error", - `[document-summary-catchup] user=${userId} failed`, - err, - ); - throw err; - } - } - return jobDone({ - jobs: jobs.length, - users, - skipped, - summaries_enqueued: summariesEnqueued, - }); - }, - ); - - // Document vault — boot-time preview-thumbnail backfill worker. The boot - // discovery sends one per-user job; this handler fans out per-document - // thumbnail jobs for that user's not-yet-thumbnailed documents. Serial - // concurrency; cheap discovery + enqueue only. - await createAndWork( - boss, - DOCUMENT_THUMBNAIL_BACKFILL_QUEUE, - { localConcurrency: DOCUMENT_THUMBNAIL_BACKFILL_CONCURRENCY }, - async (jobs) => { - let users = 0; - let skipped = 0; - let thumbnailsEnqueued = 0; - for (const job of jobs) { - const { userId } = job.data; - if (!userId) { - skipped++; - continue; - } - try { - const { enqueued } = await runThumbnailBackfillForUser(userId); - users++; - thumbnailsEnqueued += enqueued; - workerLog( - "info", - `[document-thumbnail-backfill] user=${userId} enqueued=${enqueued}`, - ); - } catch (err) { - recordError(); - workerLog( - "error", - `[document-thumbnail-backfill] user=${userId} failed`, - err, - ); - throw err; - } - } - return jobDone({ - jobs: jobs.length, - users, - skipped, - thumbnails_enqueued: thumbnailsEnqueued, - }); - }, - ); - - // v1.42 — the air-quality history backfill. Serial: every run draws on the - // same instance-wide Open-Meteo budget as the nightly fetch. - await createAndWork( - boss, - ENVIRONMENT_AQ_HISTORY_QUEUE, - { localConcurrency: 1 }, - async (jobs) => { - let failure: JobOutcome | null = null; - for (const job of jobs) { - const outcome = await handleEnvironmentAqHistory(boss, job.data); - if (!outcome.ok && failure === null) failure = outcome; - } - return failure ?? jobDone({ jobs: jobs.length }); - }, - ); - - // v1.25 — nightly environment fetch. The daily discovery tick (empty - // payload) fans out one per-user job per opted-in account; the queue also - // serves the on-demand backfill payloads from the settings surface. Serial - // concurrency so the staggered outbound fetches never crowd the request pool. - await createAndWork( - boss, - ENVIRONMENT_FETCH_QUEUE, - { localConcurrency: ENVIRONMENT_FETCH_CONCURRENCY }, - async (jobs) => { - // A discovery tick and a per-user backfill share this queue and are - // independent of each other, so the pass runs to the end and reports - // the first failure it saw. - let failure: JobOutcome | null = null; - for (const job of jobs) { - const outcome = await handleEnvironmentFetch(boss, job.data); - if (!outcome.ok && failure === null) failure = outcome; - } - return failure ?? jobDone({ jobs: jobs.length }); - }, - ); - - // v1.43 — Dawarich movement context. Serial: one account at a time, so a - // slow instance never crowds the request pool. - await createAndWork( - boss, - DAWARICH_SYNC_QUEUE, - { localConcurrency: 1 }, - (jobs) => handleDawarichSync(boss, jobs), - ); - - return allQueues; -} - -/** - * Fire-and-forget boot discovery for the duplicate dose-slot cleanup. Finds - * every user holding two live intake rows within the drift window on the same - * medication and enqueues one dedup job per account. Idempotent across reboots: - * collapsed losers are soft-deleted so the `deleted_at IS NULL` discovery - * predicate drops them. Errors are returned through the helper's result value — - * the worker boot never fails because of a dedup miss. - */ -export async function enqueueMaintenanceBootDiscovery(): Promise { - try { - const { enqueued, skipped, error } = await enqueueBootTimeIntakeSlotDedup(); - if (error) { - workerLog("error", `[intake-slot-dedup] boot discovery failed: ${error}`); - } else { - workerLog( - "info", - `[intake-slot-dedup] boot discovery: enqueued=${enqueued} skipped=${skipped}`, - ); - } - } catch (err) { - workerLog( - "error", - "[intake-slot-dedup] boot discovery threw an unexpected error", - err, - ); - } - - // v1.23 — note-encryption backfill. Finds every user still holding a - // plaintext mood/measurement note (no ciphertext yet) and enqueues one - // migration job per account. Idempotent across reboots: a migrated row nulls - // its plaintext column and drops off the discovery predicate. - try { - // Stage 5 — last of the staggered boot backfills. The heavy full-history - // loads each `localConcurrency: 1` used to drain in parallel onto one heavy - // tenant at boot; threading an increasing `startAfter` delay spreads them - // across a window. The daily cron tick (above) passes no offset and stays - // immediate. - const { enqueued, skipped, error } = - await enqueueBootTimeNoteEncryptionBackfill( - BOOT_BACKFILL_STAGGER_SECONDS * 5, - ); - if (error) { - workerLog( - "error", - `[note-encryption-backfill] boot discovery failed: ${error}`, - ); - } else { - workerLog( - "info", - `[note-encryption-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`, - ); - } - } catch (err) { - workerLog( - "error", - "[note-encryption-backfill] boot discovery threw an unexpected error", - err, - ); - } - - // v1.25 — medication free-text note encryption backfill. Finds every user - // still holding a plaintext side-effect / dose-change / inventory-item note - // (no ciphertext yet) and enqueues one migration job per account. Idempotent - // across reboots: a migrated row nulls its plaintext column and drops off the - // discovery predicate. - try { - const { enqueued, skipped, error } = - await enqueueBootTimeMedNotesEncryptionBackfill(); - if (error) { - workerLog( - "error", - `[med-notes-encryption-backfill] boot discovery failed: ${error}`, - ); - } else { - workerLog( - "info", - `[med-notes-encryption-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`, - ); - } - } catch (err) { - workerLog( - "error", - "[med-notes-encryption-backfill] boot discovery threw an unexpected error", - err, - ); - } - - // v1.39.3 — Coach title + custom-metric note encryption backfill. Staggered - // one step past the note-encryption backfill so it starts after the boot - // storm rather than on the first poll. - try { - const { enqueued, skipped, error } = - await enqueueBootTimeFreeTextEncryptionBackfill( - BOOT_BACKFILL_STAGGER_SECONDS * 6, - ); - if (error) { - workerLog( - "error", - `[free-text-encryption-backfill] boot discovery failed: ${error}`, - ); - } else { - workerLog( - "info", - `[free-text-encryption-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`, - ); - } - } catch (err) { - workerLog( - "error", - "[free-text-encryption-backfill] boot discovery threw an unexpected error", - err, - ); - } - - // Document vault — preview-thumbnail backfill. Finds every account holding a - // thumbnailable document (image or PDF) without a preview and enqueues one - // per-user backfill pass. Idempotent across reboots: a rendered thumbnail - // drops its document off the discovery predicate. - try { - const { enqueued } = await enqueueBootTimeThumbnailBackfill(); - workerLog( - "info", - `[document-thumbnail-backfill] boot discovery: enqueued=${enqueued}`, - ); - } catch (err) { - workerLog( - "error", - "[document-thumbnail-backfill] boot discovery threw an unexpected error", - err, - ); - } - - // v1.42 — the air-quality history backfill after an update, staggered past - // the boot storm. Accounts whose history is through are not offered. - try { - const { enqueued, skipped, error } = await enqueueBootTimeAirQualityHistory( - BOOT_BACKFILL_STAGGER_SECONDS * 7, - ); - if (error) { - workerLog( - "error", - `[environment-aq-history] boot discovery failed: ${error}`, - ); - } else { - workerLog( - "info", - `[environment-aq-history] boot discovery: enqueued=${enqueued} skipped=${skipped}`, - ); - } - } catch (err) { - workerLog( - "error", - "[environment-aq-history] boot discovery threw an unexpected error", - err, - ); - } - - // v1.42 — the fold repair, once per account; the purge below waits for it. - try { - const { enqueued, skipped, error } = - await enqueueBootTimeMeasurementFoldRepair(); - if (error) { - workerLog( - "error", - `[measurement-fold-repair] boot discovery failed: ${error}`, - ); - } else { - workerLog( - "info", - `[measurement-fold-repair] boot discovery: enqueued=${enqueued} skipped=${skipped}`, - ); - } - } catch (err) { - workerLog( - "error", - "[measurement-fold-repair] boot discovery threw an unexpected error", - err, - ); - } - - // v1.42 — one compaction-tombstone purge run per boot. The run is - // self-limiting and idempotent, so a boot with no backlog costs one query. - // It sends nothing until the fold repair has finished an account. - try { - const { enqueued } = await enqueueBootTimeCompactionTombstonePurge(); - workerLog( - "info", - `[compaction-tombstone-purge] boot discovery: enqueued=${enqueued}`, - ); - } catch (err) { - workerLog( - "error", - "[compaction-tombstone-purge] boot discovery threw an unexpected error", - err, - ); - } - - // v1.37.7 (issue #659) — runtime GeoLite2 fetch. Only enqueues when a - // MAXMIND_LICENSE_KEY is set, egress is not disabled, and the offline tier - // is not already present — so a self-hoster who sets the key on the - // published image gets the databases fetched on the next worker boot without - // a rebuild or a manual mount. - try { - const { enqueued } = await enqueueGeolite2FetchBootDiscovery(); - workerLog("info", `[geolite2-fetch] boot discovery: enqueued=${enqueued}`); - } catch (err) { - workerLog( - "error", - "[geolite2-fetch] boot discovery threw an unexpected error", - err, - ); - } - - // v1.44 — one-time climb backfill for workouts stored with a track but no - // elevation (`climb-backfill.ts`). Detached: it reads one track at a time - // with a pause between batches and resumes from its cursor on the next - // boot, so it never holds the boot or the heap. A finished pass returns at - // once. - trackBackgroundTask( - (async () => { - const [{ prisma }, { runWorkoutClimbBackfill }] = await Promise.all([ - import("@/lib/db"), - import("@/lib/workouts/climb-backfill"), - ]); - const result = await runWorkoutClimbBackfill({ prisma }); - if (result.scanned > 0) { - workerLog( - "info", - `[workout-climb-backfill] scanned=${result.scanned} filled=${result.filled} done=${result.done}`, - ); - } - })().catch((err) => { - workerLog( - "error", - "[workout-climb-backfill] failed; the next boot resumes from the cursor", - err, - ); - }), - ); -} diff --git a/src/lib/jobs/reminder/register-reminders.ts b/src/lib/jobs/reminder/register-reminders.ts deleted file mode 100644 index 581a81358..000000000 --- a/src/lib/jobs/reminder/register-reminders.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * Reminder-dispatch queue registrar. - * - * Owns the user-facing reminder ticks: the medication overdue check, the daily - * mood reminder, the cycle reminder (period-soon + period-start-confirm), the - * Vorsorge (measurement) reminder, and the eventful reminder-satisfy queue. - * - * v1.4.37 dead-queue contract: every queue name appears in `allQueues`, its - * cron (where it has one) appears as a `[QUEUE, CRON]` tuple in `schedules`, - * and a `createAndWork(boss, QUEUE, …, handler)` binding drains it. The - * `cycle-reminder-queue` and `measurement-reminder-queue` guards read THIS - * module. - * - * Retry policy: every queue here keeps the pg-boss default. These ticks are - * fan-out passes whose per-user failures never fail the job, so a job-level - * failure means the pass itself could not run — a transient the default two - * retries are the right answer to. The satisfy queue carries its own - * `retryLimit` at send time (see `reminder-satisfy.ts`). - */ -import { PgBoss } from "pg-boss"; -import { - REMINDER_SATISFY_QUEUE, - REMINDER_SATISFY_CONCURRENCY, - type ReminderSatisfyPayload, -} from "@/lib/jobs/reminder-satisfy"; -import { - createAndSchedule, - createAndWork, - type ScheduleEntry, -} from "./registrar-shared"; -import { - ReminderCheckPayload, - handleReminderCheck, -} from "./medication-reminder-check"; -import { - MoodReminderPayload, - CycleReminderPayload, - MeasurementReminderPayload, - handleMoodReminderCheck, - handleCycleReminderCheck, - handleMeasurementReminderCheck, - handleReminderSatisfy, -} from "./mood-cycle-checks"; - -const QUEUE_NAME = "medication-reminder-check"; - -const CHECK_INTERVAL_CRON = "*/15 * * * *"; // every 15 minutes -// v0.5.4 ios-coord — daily mood-reminder cron. -// -// Runs every 15 minutes so the handler can pick up users whose local -// time has just crossed 22:00 across any IANA timezone without having -// to schedule one cron entry per zone. The handler short-circuits when -// the user's local hour isn't 22, so the 15-min cadence translates to -// ~4 ticks-per-hour × 1 actual-dispatch-window-per-user = at most one -// push per user per day. Idempotency is enforced by the -// `MoodReminderDispatch` ledger inside the handler. - -const MOOD_REMINDER_QUEUE = "mood-reminder-check"; - -const MOOD_REMINDER_CRON = "*/15 * * * *"; -// v1.15 — daily cycle reminder cron (period-soon + period-start-confirm). -// -// Runs every 15 minutes for the same reason as the mood reminder: the -// handler short-circuits unless the candidate user's local time is the -// cycle-reminder hour (09:00), so the 15-min cadence picks up every IANA -// timezone crossing that hour without one cron entry per zone. At most one -// push per event per user per local day — the `push_attempts` ledger is the -// idempotency anchor inside the handler. - -const CYCLE_REMINDER_QUEUE = "cycle-reminder-check"; - -const CYCLE_REMINDER_CRON = "*/15 * * * *"; - -// v1.17.1 — every-15-min tick for Vorsorge (measurement) reminders. -// Same cadence + short-circuit shape as the mood / cycle reminders: the -// handler only fires a reminder whose `nextDueAt` is past AND whose local -// time is the reminder's `notifyHour`, so the 15-min cadence picks up -// every IANA timezone crossing that hour without one cron entry per zone. -// Dedup is the reminder's own `nextDueAt` advance — no ledger table. - -const MEASUREMENT_REMINDER_QUEUE = "measurement-reminder-check"; - -const MEASUREMENT_REMINDER_CRON = "*/15 * * * *"; - -const allQueues = [ - QUEUE_NAME, - // v0.5.4 ios-coord — mood-reminder cron tick. Same pg-boss v12 - // createQueue contract as the drain queue; without this entry the - // every-15-min schedule silently no-ops and the dispatcher never fires. - MOOD_REMINDER_QUEUE, - // v1.15 — cycle-reminder cron tick (period-soon + period-start-confirm). - // Without this entry the every-15-min schedule silently no-ops and the - // cycle dispatcher never fires (the v1.4.37 dead-queue class). - CYCLE_REMINDER_QUEUE, - // v1.17.1 — Vorsorge (measurement) reminder cron tick. Without this entry - // pg-boss never provisions the queue and the every-15-min schedule - // silently no-ops (the v1.4.37 dead-queue class). - MEASUREMENT_REMINDER_QUEUE, - // v1.18.1 — eventful Vorsorge satisfaction. No cron of its own (the - // 15-min measurement-reminder check is the safety-net); enqueued by the - // ingest paths. Must still be registered here or the worker binding - // below never provisions the queue (v1.4.37 dead-queue lesson). - REMINDER_SATISFY_QUEUE, -]; - -const schedules: ScheduleEntry[] = [ - [QUEUE_NAME, CHECK_INTERVAL_CRON], - // v0.5.4 ios-coord — every-15-min tick for the daily mood reminder. - // The handler short-circuits unless the candidate user's local - // time is the 22:00 hour, so the cron costs ~one user-row scan - // per tick for the entire opted-in cohort. - [MOOD_REMINDER_QUEUE, MOOD_REMINDER_CRON], - // v1.15 — every-15-min tick for the daily cycle reminder. The handler - // short-circuits unless the candidate user's local time is the 09:00 - // hour, so the cron costs ~one prediction-row scan per tick for the - // opted-in cohort. - [CYCLE_REMINDER_QUEUE, CYCLE_REMINDER_CRON], - // v1.17.1 — every-15-min tick for the Vorsorge (measurement) reminder. - // The handler short-circuits unless a reminder is past-due AND the - // user's local time matches the reminder's notifyHour, so the cron - // costs ~one reminder-row scan per tick for the active cohort. - [MEASUREMENT_REMINDER_QUEUE, MEASUREMENT_REMINDER_CRON], -]; - -/** - * Register every reminder-dispatch queue. Returns the queue names created (for - * the boot-level aggregate assertion). - */ -export async function registerReminderQueues( - boss: PgBoss, -): Promise { - await createAndSchedule(boss, allQueues, schedules); - - await createAndWork( - boss, - QUEUE_NAME, - { localConcurrency: 1 }, - handleReminderCheck, - ); - // v0.5.4 ios-coord — single-flight worker. localConcurrency=1 keeps - // two reminder ticks from interleaving against the same user row; - // the dedup ledger would still save us, but skipping the race here - // avoids spurious P2002 errors in the wide-event log. - await createAndWork( - boss, - MOOD_REMINDER_QUEUE, - { localConcurrency: 1 }, - handleMoodReminderCheck, - ); - // v1.15 — single-flight cycle-reminder worker. localConcurrency=1 keeps - // two ticks from racing the fire-and-forget `push_attempts` ledger that - // anchors the per-day idempotency, exactly like the mood-reminder worker. - await createAndWork( - boss, - CYCLE_REMINDER_QUEUE, - { localConcurrency: 1 }, - handleCycleReminderCheck, - ); - // v1.17.1 — single-flight Vorsorge (measurement) reminder worker. - // localConcurrency=1 keeps two ticks from racing the `nextDueAt` - // advance that anchors the per-cycle idempotency, exactly like the - // mood / cycle reminder workers. - await createAndWork( - boss, - MEASUREMENT_REMINDER_QUEUE, - { localConcurrency: 1 }, - handleMeasurementReminderCheck, - ); - // v1.18.1 — eventful Vorsorge satisfaction. Resolves a user's reminders - // against their just-landed measurement / lab. Read-heavy on the user's - // own data; the same small concurrency budget as PR detection. - await createAndWork( - boss, - REMINDER_SATISFY_QUEUE, - { localConcurrency: REMINDER_SATISFY_CONCURRENCY }, - handleReminderSatisfy, - ); - - return allQueues; -} diff --git a/src/lib/jobs/reminder/register-rollup.ts b/src/lib/jobs/reminder/register-rollup.ts deleted file mode 100644 index f4ed00269..000000000 --- a/src/lib/jobs/reminder/register-rollup.ts +++ /dev/null @@ -1,1156 +0,0 @@ -/** - * Rollup / consolidation queue registrar. - * - * Owns the pre-aggregation + consolidation tier: measurement-rollup recompute - * + boot-backfill, mood-rollup recompute + boot-backfill, medication-compliance - * boot-backfill, legacy step consolidation, daily-mean consolidation, dense - * intra-day retention, and the nightly per-sample cumulative drain (which also - * folds the daily-mean + dense-retention passes onto the same tick). Includes - * the boot-time discovery enqueues for the self-converging backfills. - * - * v1.4.37 dead-queue contract: every queue name appears in `allQueues`, its - * cron (where it has one) appears as a `[QUEUE, CRON]` tuple in `schedules`, - * and a `createAndWork(boss, QUEUE, …, handler)` binding drains it. The - * `drain-cumulative-queue`, `mean-consolidation-queue`, and - * `stress-strain-retention-queue` (dense-retention facet) guards read THIS - * module. - */ -import { PgBoss } from "pg-boss"; -import { reportWorkerError } from "@/lib/jobs/report-worker-error"; -import { recordError } from "@/lib/jobs/worker-status"; -import { - ROLLUP_FULL_BACKFILL_QUEUE, - ROLLUP_FULL_BACKFILL_CONCURRENCY, - ROLLUP_RECOMPUTE_QUEUE, - ROLLUP_RECOMPUTE_CONCURRENCY, - enqueueBootTimeRollupBackfill, - recomputeUserRollups, - type RollupFullBackfillPayload, - type RollupRecomputePayload, -} from "@/lib/rollups/measurement-rollups"; -import { - MOOD_ROLLUP_FULL_BACKFILL_QUEUE, - MOOD_ROLLUP_FULL_BACKFILL_CONCURRENCY, - MOOD_ROLLUP_RECOMPUTE_QUEUE, - MOOD_ROLLUP_RECOMPUTE_CONCURRENCY, - enqueueBootTimeMoodRollupBackfill, - recomputeUserMoodRollups, - type MoodRollupFullBackfillPayload, - type MoodRollupRecomputePayload, -} from "@/lib/rollups/mood-rollups"; -import { - SUPPLEMENT_NUTRIENT_RECOMPUTE_QUEUE, - SUPPLEMENT_NUTRIENT_RECOMPUTE_CONCURRENCY, - runSupplementNutrientRecompute, - type SupplementNutrientRecomputePayload, -} from "@/lib/medications/ingredients/recompute-job"; -import { - drainPerSampleCumulative, - DRAIN_CUMULATIVE_CUTOFF_HOURS, -} from "@/lib/measurements/drain-per-sample-cumulative"; -import { - consolidateDailyMean, - MEAN_CONSOLIDATION_CUTOFF_HOURS, -} from "@/lib/measurements/consolidate-daily-mean"; -import { - STEP_CONSOLIDATION_QUEUE, - STEP_CONSOLIDATION_CONCURRENCY, - runStepConsolidationForUser, - enqueueBootTimeStepConsolidation, - type StepConsolidationPayload, -} from "@/lib/jobs/step-consolidation"; -import { - STEP_CONSOLIDATION_REPAIR_QUEUE, - STEP_CONSOLIDATION_REPAIR_CONCURRENCY, - runStepConsolidationRepairForUser, - enqueueBootTimeStepConsolidationRepair, - type StepConsolidationRepairPayload, -} from "@/lib/jobs/step-consolidation-repair"; -import { - CUMULATIVE_PR_REDERIVE_QUEUE, - CUMULATIVE_PR_REDERIVE_CONCURRENCY, - runCumulativePrRederivationForUser, - enqueueBootTimeCumulativePrRederivation, - type CumulativePrRederivePayload, -} from "@/lib/personal-records/cumulative-pr-rederivation"; -import { - MEAN_CONSOLIDATION_QUEUE, - MEAN_CONSOLIDATION_CONCURRENCY, - runMeanConsolidationForUser, - enqueueBootTimeMeanConsolidation, - type MeanConsolidationPayload, -} from "@/lib/jobs/mean-consolidation"; -import { - DENSE_INTRADAY_RETENTION_QUEUE, - DENSE_INTRADAY_RETENTION_CONCURRENCY, - runDenseIntradayRetentionForUser, - enqueueBootTimeDenseIntradayRetention, - DENSE_INTRADAY_RETENTION_ENABLED, - type DenseIntradayRetentionPayload, -} from "@/lib/jobs/dense-intraday-retention"; -import { runDenseIntradayRetention } from "@/lib/measurements/dense-intraday-retention"; -import { - DENSE_INTRADAY_HOURLY_REBUILD_QUEUE, - DENSE_INTRADAY_HOURLY_REBUILD_CONCURRENCY, - runDenseIntradayHourlyRebuildForUser, - enqueueBootTimeDenseIntradayHourlyRebuild, - type DenseIntradayHourlyRebuildPayload, -} from "@/lib/jobs/dense-intraday-hourly-rebuild"; -import { - getWorkerPrisma, - workerLog, - BOOT_BACKFILL_STAGGER_SECONDS, -} from "./shared"; -import { heavyLane } from "@/lib/jobs/heavy-lane"; -import { lockedPass } from "@/lib/jobs/long-pass"; -import { - createAndSchedule, - createAndWork, - cronIsTheRetry, - retireQueues, - type QueuePolicyTable, - type ScheduleEntry, -} from "./registrar-shared"; -import { jobDone, jobFailed } from "@/lib/jobs/job-outcome"; -import { jobBudget } from "@/lib/jobs/job-budget"; -import { - DRAIN_CONTINUATION_DELAY_SECONDS, - nextDrainContinuation, -} from "@/lib/jobs/drain-continuation"; -// v1.4.37 — nightly drain of per-sample APPLE_HEALTH cumulative rows. -// Collapses each user × cumulative-type × calendar-day bucket into one -// `stats:…` row so the list view stops painting hundreds of step chunks per -// day. 03:45 Europe/Berlin slots in between the 03:15 audit-log cleanup and -// the 04:00 feedback aggregator. The 36-hour grace window keeps today + the -// trailing watch-sync window intact for real-time visibility; only -// completed-and-stable days fall to the drain. - -const DRAIN_CUMULATIVE_QUEUE = "drain-per-sample-cumulative"; - -const DRAIN_CUMULATIVE_CRON = "45 3 * * *"; - -interface DrainCumulativePayload { - triggeredAt?: string; - /** - * How many times this tick has already handed itself on. Absent on the - * cron's own job. See `nextDrainContinuation`. - */ - continuation?: number; -} - -const allQueues = [ - ROLLUP_RECOMPUTE_QUEUE, - ROLLUP_FULL_BACKFILL_QUEUE, - // v1.4.39 — per-bucket WEEK/MONTH/YEAR fold queue for the - // mood rollup tier. The DAY pass runs inline in the write hook; - // these enqueue paths cover the cross-granularity buckets the - // worker materialises off the request path. - MOOD_ROLLUP_RECOMPUTE_QUEUE, - // v1.4.39 — boot-time fold queue for the mood rollup tier. - MOOD_ROLLUP_FULL_BACKFILL_QUEUE, - // v1.5.6 — boot-time legacy step consolidation. Discovery enqueues - // one job per user still holding live pre-v1.5.0 granular step rows. - STEP_CONSOLIDATION_QUEUE, - // v1.28.37 — one-shot repair for the provider step rows the pre-fix - // consolidation swept. Boot-discovery driven, self-converging. - STEP_CONSOLIDATION_REPAIR_QUEUE, - // v1.30.3 — one-shot repair for the cumulative-type PersonalRecord rows - // the pre-fix multi-source SUM inflated. Boot-discovery driven, - // self-converging (QA F4). - CUMULATIVE_PR_REDERIVE_QUEUE, - // v1.7.0 — daily-mean consolidation for high-frequency spot HealthKit - // metrics (walking speed/step length, respiratory rate, audio exposure). - MEAN_CONSOLIDATION_QUEUE, - // v1.10.0 — computed scores (WX-E). Dense intra-day retention drain of - // daytime HRV / HR samples (per-user backfill queue, boot-discovery - // driven like mean-consolidation; the steady-state nightly walk folds - // onto the drain-cumulative tick). - DENSE_INTRADAY_RETENTION_QUEUE, - // v1.28.31 — one-shot hourly history rebuild for pre-hourly folded - // dense-tier days. Boot-discovery driven and self-converging (a rebuilt - // day's retired daily row drops it from the discovery pairing). - DENSE_INTRADAY_HOURLY_REBUILD_QUEUE, - // v1.4.37 — explicit createQueue is required before the nightly - // schedule below registers (pg-boss v12 contract). Without this entry the - // drain schedule silently no-ops and the per-sample APPLE_HEALTH rows - // never collapse. - DRAIN_CUMULATIVE_QUEUE, - // v1.43 — one account's supplement doses recomputed into the day's - // micronutrients after a change that spans more than one day. - SUPPLEMENT_NUTRIENT_RECOMPUTE_QUEUE, -]; - -const schedules: ScheduleEntry[] = [ - // v1.4.37 — nightly fold of per-sample APPLE_HEALTH cumulative - // rows into one row per day per type. Slots between the - // audit-log cleanup (03:15) and the feedback aggregator (04:00). - [DRAIN_CUMULATIVE_QUEUE, DRAIN_CUMULATIVE_CRON, cronIsTheRetry], -]; - -/** - * De-duplication policy per rollup queue. Until this table existed every queue - * here ran under pg-boss's `standard` policy, under which no unique index - * constrains `singleton_key` — so the `singletonKey` each enqueue site passes - * was inert and de-duplicated nothing. - * - * The split below is deliberate and the two halves are NOT interchangeable. - */ -const queuePolicies: QueuePolicyTable = { - // `short`, not `exclusive`. A recompute reads the bucket's live rows when it - // STARTS, so collapsing sends that pile up while an identical job is still - // queued is safe — the queued job will observe every write that landed in the - // meantime. This is what kills the storm: a sync posting a few hundred batch - // requests fanned out three jobs per type-day per request, thousands of jobs - // re-deriving byte-identical rows. Under `short` they collapse to one queued - // job per bucket. `exclusive` would be wrong here: it would also suppress a - // send issued after the recompute had already started reading, stranding the - // newer measurement in a stale bucket until the nightly pass. - [ROLLUP_RECOMPUTE_QUEUE]: { - policy: "short", - reason: - "Burst coalescing for the write-path fan-out. The handler re-reads the bucket at run time, so collapsing queued duplicates cannot strand a write; exclusive could.", - }, - [MOOD_ROLLUP_RECOMPUTE_QUEUE]: { - policy: "short", - reason: - "Same shape as the measurement recompute: re-reads the bucket at run time, so only queued duplicates may be collapsed.", - }, - [SUPPLEMENT_NUTRIENT_RECOMPUTE_QUEUE]: { - policy: "short", - reason: - "Per-account recompute of supplement nutrient rows; the pass reads the live doses and composition when it starts, so a queued duplicate may be collapsed, while a send during a running pass must still queue the next one.", - }, - - // The rest are per-user, self-converging boot/discovery backfills. A second - // concurrent run is pure duplicated work, and the discovery predicate - // re-enqueues on the next boot or cron tick while work remains outstanding — - // so `exclusive` (queued OR active OR awaiting retry) can never lose work it - // does not re-offer. `short` would be too weak: these passes run for minutes - // to hours, and the observed failure was precisely a worker restarting - // mid-pass and appending another identical full-history job per restart. - [ROLLUP_FULL_BACKFILL_QUEUE]: { - policy: "exclusive", - reason: - "Per-user full-history backfill; long-running. Boot discovery re-enqueues while coverage is still missing, so suppressing a duplicate of an ACTIVE pass is safe.", - }, - [MOOD_ROLLUP_FULL_BACKFILL_QUEUE]: { - policy: "exclusive", - reason: - "Per-user mood backfill; boot-discovery driven and self-converging on rollup coverage.", - }, - [STEP_CONSOLIDATION_QUEUE]: { - policy: "exclusive", - reason: - "Per-user legacy step consolidation; discovery drops the user once no live granular rows remain.", - }, - [STEP_CONSOLIDATION_REPAIR_QUEUE]: { - policy: "exclusive", - reason: - "Per-user one-shot repair; discovery drops the user once the affected provider rows are repaired.", - }, - [CUMULATIVE_PR_REDERIVE_QUEUE]: { - policy: "exclusive", - reason: - "Per-user one-shot re-derivation; discovery drops the user once the inflated records are rewritten.", - }, - [MEAN_CONSOLIDATION_QUEUE]: { - policy: "exclusive", - reason: - "Per-user daily-mean consolidation; discovery drops the user once the spot rows are folded.", - }, - [DENSE_INTRADAY_RETENTION_QUEUE]: { - policy: "exclusive", - reason: - "Per-user dense-tier drain; the multi-hour pass on a heavy account is exactly the case where a restart used to append a duplicate full-history job.", - }, - [DENSE_INTRADAY_HOURLY_REBUILD_QUEUE]: { - policy: "exclusive", - reason: - "Per-user one-shot hourly rebuild; discovery drops the day once its retired daily row is gone.", - }, - - // DRAIN_CUMULATIVE_QUEUE is deliberately absent: it is a keyless nightly cron - // tick with no singletonKey on any send, so a policy would only constrain the - // empty key and buy nothing. -}; - -/** - * The boot backfill of the per-day medication-compliance rollup, whose table - * had no reader left and was dropped. Its queue, and any job still waiting in - * it, is removed on an instance that still has it. - */ -const RETIRED_ROLLUP_QUEUES = ["medication-compliance-full-backfill"] as const; - -/** - * Register every rollup / consolidation queue. Returns the queue names created - * (for the boot-level aggregate assertion). - */ -export async function registerRollupQueues( - boss: PgBoss, -): Promise { - await createAndSchedule(boss, allQueues, schedules, queuePolicies); - await retireQueues(boss, RETIRED_ROLLUP_QUEUES); - - // v1.5.0 — persistent measurement rollup worker. Folds the - // WEEK / MONTH / YEAR buckets that the write-path hooks enqueue; - // the DAY bucket is already recomputed synchronously by the hook - // itself. Concurrency-2 keeps two recomputes in flight without - // crowding the dashboard request pool. - await createAndWork( - boss, - ROLLUP_RECOMPUTE_QUEUE, - { localConcurrency: ROLLUP_RECOMPUTE_CONCURRENCY }, - async (jobs) => { - for (const job of jobs) { - const payload = job.data; - await recomputeUserRollups(payload.userId, { - types: [payload.type], - granularities: [payload.granularity], - from: new Date(payload.from), - to: new Date(payload.to), - }); - } - // No per-bucket isolation here on purpose: a recompute that throws - // fails the job, so reaching this line means every bucket in the batch - // was folded. - return jobDone({ buckets_recomputed: jobs.length }); - }, - ); - - // v1.4.35.1 — boot-time full-fold worker. The boot enqueue helper - // below sends one job per uncovered user; this handler runs the - // full `recomputeUserRollups` against the default 5-year window - // across every granularity. Serial concurrency so the populator - // never crowds the dashboard request pool. - await createAndWork( - boss, - ROLLUP_FULL_BACKFILL_QUEUE, - { localConcurrency: ROLLUP_FULL_BACKFILL_CONCURRENCY }, - // The lock sits outside the lane: a retry pg-boss starts after the expiry - // while the first fold still runs finds the account's lock and settles, - // instead of waiting in the lane and folding the account again. - lockedPass( - ROLLUP_FULL_BACKFILL_QUEUE, - (job) => job.data.userId, - heavyLane(ROLLUP_FULL_BACKFILL_QUEUE, async (jobs) => { - const shouldStop = jobBudget(jobs); - let rowsUpsertedTotal = 0; - for (const job of jobs) { - const { userId } = job.data; - const { rowsUpserted, durationMs, stopped } = - await recomputeUserRollups(userId, { shouldStop }); - if (stopped) { - // Each granularity committed on its own; the retry folds again. - throw new Error( - `rollup-full-backfill for user ${userId} stopped on its job budget; the retry folds the rest`, - ); - } - rowsUpsertedTotal += rowsUpserted; - workerLog( - "info", - `[rollup-full-backfill] user=${userId} rows=${rowsUpserted} duration=${durationMs}ms`, - ); - } - return jobDone({ - users: jobs.length, - rows_upserted: rowsUpsertedTotal, - }); - }), - ), - ); - - // v1.5.6 — legacy step consolidation worker. The boot enqueue helper - // below sends one job per user still holding live pre-v1.5.0 granular - // step rows; this handler collapses them into one daily-total row per - // calendar day and soft-deletes the originals. Serial concurrency so - // the populator never crowds the dashboard request pool. - await createAndWork( - boss, - STEP_CONSOLIDATION_QUEUE, - { localConcurrency: STEP_CONSOLIDATION_CONCURRENCY }, - async (jobs) => { - let daysTotal = 0; - let legacyRowsTotal = 0; - for (const job of jobs) { - const { userId } = job.data; - const { daysConsolidated, legacyRowsSoftDeleted } = - await runStepConsolidationForUser(userId); - daysTotal += daysConsolidated; - legacyRowsTotal += legacyRowsSoftDeleted; - workerLog( - "info", - `[step-consolidation] user=${userId} days=${daysConsolidated} legacyRowsSoftDeleted=${legacyRowsSoftDeleted}`, - ); - } - return jobDone({ - users: jobs.length, - days_consolidated: daysTotal, - legacy_rows_soft_deleted: legacyRowsTotal, - }); - }, - ); - - // v1.28.37 — step-consolidation repair worker. Resurrects the - // GOOGLE_HEALTH / FITBIT daily-total step rows the pre-fix consolidation - // swept and removes the shadow MANUAL totals it minted. Serial - // concurrency so the repair never crowds the dashboard request pool. - await createAndWork( - boss, - STEP_CONSOLIDATION_REPAIR_QUEUE, - { localConcurrency: STEP_CONSOLIDATION_REPAIR_CONCURRENCY }, - async (jobs) => { - let rowsResurrected = 0; - let wedgeSkipped = 0; - let manualMintsRemoved = 0; - let daysRecomputed = 0; - let failures = 0; - for (const job of jobs) { - const { userId } = job.data; - const summary = await runStepConsolidationRepairForUser(userId); - rowsResurrected += summary.rowsResurrected; - wedgeSkipped += summary.wedgeSkipped; - manualMintsRemoved += summary.manualMintsRemoved; - daysRecomputed += summary.daysRecomputed; - // The repair isolates its own per-day failures and reports them as a - // count, so they ride out as a fact rather than failing the job. - failures += summary.failures; - workerLog( - "info", - `[step-consolidation-repair] user=${userId} resurrected=${summary.rowsResurrected} wedgeSkipped=${summary.wedgeSkipped} manualMintsRemoved=${summary.manualMintsRemoved} daysRecomputed=${summary.daysRecomputed} failures=${summary.failures}`, - ); - } - return jobDone({ - users: jobs.length, - rows_resurrected: rowsResurrected, - wedge_skipped: wedgeSkipped, - manual_mints_removed: manualMintsRemoved, - days_recomputed: daysRecomputed, - failures, - }); - }, - ); - - // v1.43 — supplement nutrient recompute. One account per job, serial. - await createAndWork( - boss, - SUPPLEMENT_NUTRIENT_RECOMPUTE_QUEUE, - { localConcurrency: SUPPLEMENT_NUTRIENT_RECOMPUTE_CONCURRENCY }, - async (jobs) => { - let rowsWritten = 0; - for (const job of jobs) { - const result = await runSupplementNutrientRecompute(job.data); - rowsWritten += result.rowsWritten; - } - return jobDone({ users: jobs.length, rows_written: rowsWritten }); - }, - ); - - // v1.30.3 — cumulative-PersonalRecord rederivation worker (QA F4). The - // boot enqueue helper below sends one job per user still holding a - // suspect pre-fix inflated cumulative-type record; this handler deletes - // it and re-runs detection silently so the honest re-derived best takes - // its place. Serial concurrency so the repair never crowds the - // dashboard request pool. - await createAndWork( - boss, - CUMULATIVE_PR_REDERIVE_QUEUE, - { localConcurrency: CUMULATIVE_PR_REDERIVE_CONCURRENCY }, - async (jobs) => { - let rowsDeleted = 0; - let rowsReinserted = 0; - for (const job of jobs) { - const { userId } = job.data; - const summary = await runCumulativePrRederivationForUser(userId); - rowsDeleted += summary.rowsDeleted; - rowsReinserted += summary.rowsReinserted; - workerLog( - "info", - `[cumulative-pr-rederive] user=${userId} rowsDeleted=${summary.rowsDeleted} rowsReinserted=${summary.rowsReinserted}`, - ); - } - return jobDone({ - users: jobs.length, - rows_deleted: rowsDeleted, - rows_reinserted: rowsReinserted, - }); - }, - ); - - // v1.7.0 — daily-mean consolidation worker. The boot enqueue helper - // below sends one job per user holding live per-sample high-frequency - // mean-type rows; this handler collapses each completed day to its - // mean and soft-deletes the originals. Serial concurrency so the - // populator never crowds the dashboard request pool. - await createAndWork( - boss, - MEAN_CONSOLIDATION_QUEUE, - { localConcurrency: MEAN_CONSOLIDATION_CONCURRENCY }, - async (jobs) => { - const shouldStop = jobBudget(jobs); - let daysTotal = 0; - let perSampleRowsTotal = 0; - let stoppedEarly = false; - for (const job of jobs) { - const { userId } = job.data; - const summary = await runMeanConsolidationForUser(userId, shouldStop); - const { daysConsolidated, perSampleRowsSoftDeleted } = summary; - stoppedEarly ||= summary.stoppedEarly; - daysTotal += daysConsolidated; - perSampleRowsTotal += perSampleRowsSoftDeleted; - workerLog( - "info", - `[mean-consolidation] user=${userId} days=${daysConsolidated} perSampleRowsSoftDeleted=${perSampleRowsSoftDeleted}`, - ); - } - // Out of budget is a completed run, not a failed one: the folded days - // are committed, and the nightly tick carries on from the first day - // this run did not reach. - return jobDone({ - users: jobs.length, - days_consolidated: daysTotal, - per_sample_rows_soft_deleted: perSampleRowsTotal, - stopped_early: stoppedEarly, - }); - }, - ); - - // v1.10.0 WX-E — dense intra-day retention per-user backfill worker. The - // boot enqueue helper below sends one job per user holding live per-sample - // dense-tier (HRV / HR / SpO2) rows older than the retention window; this - // handler folds those out-of-window samples to hourly means (v1.28.31) and - // soft-deletes the originals, keeping the in-window intra-day shape intact - // for the Stress engine. Serial concurrency so the backfill never crowds - // the request pool. - await createAndWork( - boss, - DENSE_INTRADAY_RETENTION_QUEUE, - { localConcurrency: DENSE_INTRADAY_RETENTION_CONCURRENCY }, - async (jobs) => { - const shouldStop = jobBudget(jobs); - let daysTotal = 0; - let perSampleRowsTotal = 0; - let derivedRestingRowsTotal = 0; - let stoppedEarly = false; - for (const job of jobs) { - const { userId } = job.data; - try { - const summary = await runDenseIntradayRetentionForUser( - userId, - shouldStop, - ); - const { - daysConsolidated, - perSampleRowsSoftDeleted, - derivedRestingRowsUpserted, - } = summary; - stoppedEarly ||= summary.stoppedEarly; - daysTotal += daysConsolidated; - perSampleRowsTotal += perSampleRowsSoftDeleted; - derivedRestingRowsTotal += derivedRestingRowsUpserted; - workerLog( - "info", - `[dense-intraday-retention] user=${userId} days=${daysConsolidated} perSampleRowsSoftDeleted=${perSampleRowsSoftDeleted} derivedRestingRowsUpserted=${derivedRestingRowsUpserted}`, - ); - } catch (err) { - recordError(); - workerLog( - "error", - `[dense-intraday-retention] user=${userId} failed`, - err, - ); - throw err; - } - } - return jobDone({ - users: jobs.length, - days_consolidated: daysTotal, - per_sample_rows_soft_deleted: perSampleRowsTotal, - derived_resting_rows_upserted: derivedRestingRowsTotal, - // Out of budget completes the job: the folded days are committed and - // the nightly tick, which hands itself on while work remains, - // continues from the first day this run did not reach. - stopped_early: stoppedEarly, - }); - }, - ); - - // v1.28.31 — one-shot hourly history rebuild worker. The boot enqueue - // helper below sends one job per user still holding a pre-hourly daily - // `stats:` row paired with tombstoned raw rows; this handler reconstructs - // hourly means from the tombstones and retires the daily row atomically. - // Self-converging: a rebuilt day drops out of the discovery pairing, so - // the pass runs once per install. Serial concurrency so the rebuild never - // crowds the request pool. - await createAndWork( - boss, - DENSE_INTRADAY_HOURLY_REBUILD_QUEUE, - { localConcurrency: DENSE_INTRADAY_HOURLY_REBUILD_CONCURRENCY }, - async (jobs) => { - const shouldStop = jobBudget(jobs); - let stoppedEarly = false; - let daysRebuiltTotal = 0; - let hourlyRowsTotal = 0; - let dailyRowsRetiredTotal = 0; - let daysSkippedTotal = 0; - for (const job of jobs) { - const { userId } = job.data; - try { - const summary = await runDenseIntradayHourlyRebuildForUser( - userId, - shouldStop, - ); - const { - daysRebuilt, - hourlyRowsUpserted, - dailyRowsRetired, - daysSkippedNoTombstones, - } = summary; - stoppedEarly ||= summary.stoppedEarly; - daysRebuiltTotal += daysRebuilt; - hourlyRowsTotal += hourlyRowsUpserted; - dailyRowsRetiredTotal += dailyRowsRetired; - daysSkippedTotal += daysSkippedNoTombstones; - workerLog( - "info", - `[dense-intraday-hourly-rebuild] user=${userId} daysRebuilt=${daysRebuilt} hourlyRowsUpserted=${hourlyRowsUpserted} dailyRowsRetired=${dailyRowsRetired} daysSkippedNoTombstones=${daysSkippedNoTombstones}`, - ); - } catch (err) { - recordError(); - workerLog( - "error", - `[dense-intraday-hourly-rebuild] user=${userId} failed`, - err, - ); - throw err; - } - } - const did = { - users: jobs.length, - stopped_early: stoppedEarly, - days_rebuilt: daysRebuiltTotal, - hourly_rows_upserted: hourlyRowsTotal, - daily_rows_retired: dailyRowsRetiredTotal, - days_skipped_no_tombstones: daysSkippedTotal, - }; - // Out of budget: fail so pg-boss retries (the boot send allows three - // retries), and the retry starts at the first day not yet rebuilt. The - // queue is `exclusive`, so the job cannot hand itself on while active. - if (stoppedEarly) { - return jobFailed( - "stopped before every day was rebuilt; the retry resumes", - undefined, - did, - ); - } - return jobDone(did); - }, - ); - - // v1.4.39 — mood-rollup per-bucket worker. Folds the - // WEEK / MONTH / YEAR buckets that the mood-entry write hooks - // enqueue; the DAY bucket runs synchronously in the hook itself. - // No current read path consumes these buckets — they exist so a - // future cross-granularity reader can ship without a backfill - // step. Concurrency-2 mirrors the measurement-rollup worker. - await createAndWork( - boss, - MOOD_ROLLUP_RECOMPUTE_QUEUE, - { localConcurrency: MOOD_ROLLUP_RECOMPUTE_CONCURRENCY }, - async (jobs) => { - for (const job of jobs) { - const payload = job.data; - await recomputeUserMoodRollups(payload.userId, { - granularities: [payload.granularity], - from: new Date(payload.from), - to: new Date(payload.to), - }); - } - return jobDone({ buckets_recomputed: jobs.length }); - }, - ); - - // v1.4.39 — mood-rollup boot-time fold worker. The boot - // discovery helper below sends one job per user with mood entries - // but zero rollup rows; this handler folds the full 5-year window - // across every granularity. Concurrency-1 so the populator never - // crowds the request pool. - await createAndWork( - boss, - MOOD_ROLLUP_FULL_BACKFILL_QUEUE, - { localConcurrency: MOOD_ROLLUP_FULL_BACKFILL_CONCURRENCY }, - async (jobs) => { - let rowsUpsertedTotal = 0; - for (const job of jobs) { - const { userId } = job.data; - try { - // v1.32.12 — delete-then-refold. Empties the user's rollup - // rows before refolding so the boot backfill is self-cleaning - // against any row minted under an older keying (e.g. a - // rolling-deploy old-container write during the swap window). - // The brief empty window serves correct data via the live - // `entry.date` fallback. - const { rowsUpserted, durationMs } = await recomputeUserMoodRollups( - userId, - { replace: true }, - ); - rowsUpsertedTotal += rowsUpserted; - workerLog( - "info", - `[mood-rollup-full-backfill] user=${userId} rows=${rowsUpserted} duration=${durationMs}ms`, - ); - } catch (err) { - recordError(); - workerLog( - "error", - `[mood-rollup-full-backfill] user=${userId} failed`, - err, - ); - throw err; - } - } - return jobDone({ - users: jobs.length, - rows_upserted: rowsUpsertedTotal, - }); - }, - ); - - // v1.4.37 — nightly drain worker. Walks every user × cumulative - // type and folds per-sample APPLE_HEALTH rows older than the cutoff - // into one `stats:…` row per calendar day. Idempotent — a second run - // collapses zero buckets once every day is in the `stats:` shape. - // Concurrency-1 so the drain never crowds the dashboard request pool - // and a long backfill on the maintainer's account (300 k+ measurement rows) - // stays a single sequential walk. - await createAndWork( - boss, - DRAIN_CUMULATIVE_QUEUE, - { localConcurrency: 1 }, - async (jobs) => { - // One budget shared by all three passes, run in turn. - const shouldStop = jobBudget(jobs); - let stoppedEarly = false; - // The three drains below are independent passes sharing one nightly - // tick, so one failing pass must not stop the other two from running. - // The first failure is held back and reported once the tick is over — - // that keeps the existing order and still fails the job, which is what - // the swallowed errors never did. - let firstFailure: { reason: string; cause: unknown } | undefined; - let passesFailed = 0; - let bucketsCollapsed = 0; - let perSampleRowsDeleted = 0; - let meanDaysConsolidated = 0; - let meanRowsSoftDeleted = 0; - let denseDaysConsolidated = 0; - let denseRowsSoftDeleted = 0; - let denseSkipped = false; - - for (const job of jobs) { - try { - const summary = await drainPerSampleCumulative(getWorkerPrisma(), { - dryRun: false, - cutoffHours: DRAIN_CUMULATIVE_CUTOFF_HOURS, - shouldStop, - log: (line) => workerLog("info", line), - }); - stoppedEarly ||= summary.stoppedEarly; - bucketsCollapsed += summary.totals.bucketsCollapsed; - perSampleRowsDeleted += summary.totals.perSampleRowsDeleted; - workerLog( - "info", - `[drain-cumulative] triggeredAt=${job.data.triggeredAt} usersScanned=${summary.totals.usersScanned} bucketsCollapsed=${summary.totals.bucketsCollapsed} perSampleRowsDeleted=${summary.totals.perSampleRowsDeleted} dailyRowsUpserted=${summary.totals.dailyRowsUpserted}`, - ); - } catch (err) { - recordError(); - await reportWorkerError("drain-cumulative", err); - passesFailed++; - firstFailure ??= { reason: "cumulative drain failed", cause: err }; - } - - // v1.8.5 — fold the daily-MEAN drain onto the same nightly tick as - // the cumulative (SUM) drain. The mean drain was previously - // boot-discovery only, so between worker reboots new high-frequency - // spot samples (walking speed, respiratory rate, gait/mobility, - // audio exposure) accumulated raw. Running both passes on one - // concurrency-1 cron keeps the maintenance window a single - // sequential walk and never crowds the request pool. The global - // (no `userId`) signature drains every user; the 36-hour grace - // cutoff keeps today's in-flight watch syncs raw. Boot discovery - // stays as the back-fill for accounts that accumulated raw rows - // before this cron shipped. - try { - const meanSummary = await consolidateDailyMean(getWorkerPrisma(), { - dryRun: false, - cutoffHours: MEAN_CONSOLIDATION_CUTOFF_HOURS, - shouldStop, - log: (line) => workerLog("info", line), - }); - stoppedEarly ||= meanSummary.stoppedEarly; - meanDaysConsolidated += meanSummary.totals.daysConsolidated; - meanRowsSoftDeleted += meanSummary.totals.perSampleRowsSoftDeleted; - workerLog( - "info", - `[mean-consolidation] triggeredAt=${job.data.triggeredAt} usersScanned=${meanSummary.totals.usersScanned} daysConsolidated=${meanSummary.totals.daysConsolidated} perSampleRowsSoftDeleted=${meanSummary.totals.perSampleRowsSoftDeleted} dailyRowsUpserted=${meanSummary.totals.dailyRowsUpserted} daysFailed=${meanSummary.totals.daysFailed}`, - ); - } catch (err) { - recordError(); - await reportWorkerError("mean-consolidation", err, { - tick: "nightly", - }); - passesFailed++; - firstFailure ??= { reason: "mean consolidation failed", cause: err }; - } - - // v1.10.0 WX-E — fold the dense intra-day retention drain onto the - // same nightly tick. Unlike the daily-mean drain, this scopes to the - // dense-tier types (HEART_RATE_VARIABILITY, PULSE) and keeps the last - // DENSE_INTRADAY_RETENTION_DAYS of raw per-sample rows so the Stress - // engine still sees the intra-day SDNN shape; only out-of-window - // samples fold to a daily mean. These two types are NEVER in the - // destructive HIGH_FREQUENCY_MEAN_TYPES allowlist — the drain - // exemption the intra-day shape depends on. The global (no `userId`) - // signature drains every user. Boot discovery (below) back-fills - // accounts that accumulated out-of-window raw rows before this - // shipped. - try { - if (!DENSE_INTRADAY_RETENTION_ENABLED) { - denseSkipped = true; - workerLog( - "info", - "[dense-intraday-retention] disabled via DENSE_INTRADAY_RETENTION_ENABLED — skipping the nightly walk (operator kill-switch)", - ); - } else { - const denseSummary = await runDenseIntradayRetention( - getWorkerPrisma(), - { - dryRun: false, - shouldStop, - log: (line) => workerLog("info", line), - }, - ); - stoppedEarly ||= denseSummary.stoppedEarly; - denseDaysConsolidated += denseSummary.totals.daysConsolidated; - denseRowsSoftDeleted += - denseSummary.totals.perSampleRowsSoftDeleted; - workerLog( - "info", - `[dense-intraday-retention] triggeredAt=${job.data.triggeredAt} usersScanned=${denseSummary.totals.usersScanned} daysConsolidated=${denseSummary.totals.daysConsolidated} perSampleRowsSoftDeleted=${denseSummary.totals.perSampleRowsSoftDeleted} hourlyRowsUpserted=${denseSummary.totals.hourlyRowsUpserted} dailyRowsRetired=${denseSummary.totals.dailyRowsRetired}`, - ); - } - } catch (err) { - recordError(); - await reportWorkerError("dense-intraday-retention", err, { - tick: "nightly", - }); - passesFailed++; - firstFailure ??= { reason: "dense retention failed", cause: err }; - } - } - - // Hand the remaining work to a follow-up job when this one ran out of - // budget (bounded; see `nextDrainContinuation`). - const continuation = Math.max( - 0, - ...jobs.map((job) => job.data.continuation ?? 0), - ); - const next = nextDrainContinuation({ - stoppedEarly, - daysFolded: - bucketsCollapsed + meanDaysConsolidated + denseDaysConsolidated, - continuation, - }); - let continued = false; - if (next !== null) { - try { - const followUp: DrainCumulativePayload = { - triggeredAt: new Date().toISOString(), - continuation: next, - }; - continued = - (await boss.send(DRAIN_CUMULATIVE_QUEUE, followUp, { - ...cronIsTheRetry, - startAfter: DRAIN_CONTINUATION_DELAY_SECONDS, - })) !== null; - } catch (err) { - // The next nightly tick picks the work up regardless; a lost - // follow-up only delays it. - workerLog("error", "[drain-cumulative] follow-up send failed", err); - } - } - - const did = { - stopped_early: stoppedEarly, - continuation, - continued, - passes_failed: passesFailed, - buckets_collapsed: bucketsCollapsed, - per_sample_rows_deleted: perSampleRowsDeleted, - mean_days_consolidated: meanDaysConsolidated, - mean_rows_soft_deleted: meanRowsSoftDeleted, - dense_days_consolidated: denseDaysConsolidated, - dense_rows_soft_deleted: denseRowsSoftDeleted, - dense_skipped: denseSkipped, - }; - - if (firstFailure) { - return jobFailed(firstFailure.reason, firstFailure.cause, did); - } - return jobDone(did); - }, - ); - - return allQueues; -} - -/** - * Fire-and-forget boot discovery for the self-converging rollup / consolidation - * backfills. Each pass is idempotent across reboots and never fails worker boot - * on a miss (errors come back through the helper's result value). - * - * Each backfill is `localConcurrency: 1` on its own, but every one of them used - * to drain from the first pg-boss poll at boot — on a heavy tenant that meant - * several full-history loads contending for the connection pool at once (a boot - * storm / crash-loop risk). Each type now gets an increasing - * `BOOT_BACKFILL_STAGGER_SECONDS` multiple as a `startAfter` delay so their - * loads spread across a window instead of all landing on the same poll. Dense - * intra-day retention keeps its own larger boot defer (the established P2028 - * pool-exhaustion fix) and stays the furthest-out stage. - */ -export async function enqueueRollupBootDiscovery(): Promise { - // v1.4.35.1 — measurement-rollup backfill. Finds every user with - // measurements but no rollup coverage and enqueues a full-fold per account. - // The discovery query only matches accounts with zero rollup rows, so once - // a fold completes the user drops off the list. - try { - // Stage 0 — first off the line; no stagger delay. - const { enqueued, skipped, error } = await enqueueBootTimeRollupBackfill( - BOOT_BACKFILL_STAGGER_SECONDS * 0, - ); - if (error) { - workerLog( - "error", - `[rollup-full-backfill] boot discovery failed: ${error}`, - ); - } else { - // v1.4.38.7 — log the discovery result on every boot, including - // the silent `enqueued=0 skipped=0` case. Without this, an - // operator chasing "analytics is slow" cannot tell whether the - // discovery query ran successfully (and found nothing to fold) - // vs. silently no-op'd. The line is one row per worker boot, so - // the log cost is negligible. - workerLog( - "info", - `[rollup-full-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`, - ); - } - } catch (err) { - workerLog( - "error", - "[rollup-full-backfill] boot discovery threw an unexpected error", - err, - ); - } - - // v1.5.6 — legacy step consolidation. Finds every user still holding live - // pre-v1.5.0 granular step rows and enqueues one consolidation job per - // account. Consolidated legacy rows are soft-deleted, so the - // `deleted_at IS NULL` discovery predicate drops them. - try { - // Stage 1. - const { enqueued, skipped, error } = await enqueueBootTimeStepConsolidation( - BOOT_BACKFILL_STAGGER_SECONDS * 1, - ); - if (error) { - workerLog( - "error", - `[step-consolidation] boot discovery failed: ${error}`, - ); - } else { - workerLog( - "info", - `[step-consolidation] boot discovery: enqueued=${enqueued} skipped=${skipped}`, - ); - } - } catch (err) { - workerLog( - "error", - "[step-consolidation] boot discovery threw an unexpected error", - err, - ); - } - - // v1.7.0 — daily-mean consolidation. Finds every user holding live - // per-sample high-frequency mean-type rows and enqueues one job per account. - // Consolidated rows are soft-deleted, so the discovery predicate drops them. - try { - // Stage 2. - const { enqueued, skipped, error } = await enqueueBootTimeMeanConsolidation( - BOOT_BACKFILL_STAGGER_SECONDS * 2, - ); - if (error) { - workerLog( - "error", - `[mean-consolidation] boot discovery failed: ${error}`, - ); - } else { - workerLog( - "info", - `[mean-consolidation] boot discovery: enqueued=${enqueued} skipped=${skipped}`, - ); - } - } catch (err) { - workerLog( - "error", - "[mean-consolidation] boot discovery threw an unexpected error", - err, - ); - } - - // v1.10.0 WX-E — dense intra-day retention drain. Finds every user holding - // live per-sample dense-tier (HRV / HR) rows OLDER than the retention window - // and enqueues one job per account. Folded rows are soft-deleted, so the - // discovery predicate drops them. - try { - // Furthest-out stage — the helper self-defers past the boot window via its - // own larger constant (the P2028 pool-exhaustion fix), so no stagger - // argument is threaded here. - const { enqueued, skipped, error } = - await enqueueBootTimeDenseIntradayRetention(); - if (error) { - workerLog( - "error", - `[dense-intraday-retention] boot discovery failed: ${error}`, - ); - } else { - workerLog( - "info", - `[dense-intraday-retention] boot discovery: enqueued=${enqueued} skipped=${skipped}`, - ); - } - } catch (err) { - workerLog( - "error", - "[dense-intraday-retention] boot discovery threw an unexpected error", - err, - ); - } - - // v1.28.31 — one-shot hourly history rebuild. Finds every user still - // holding a pre-hourly daily dense-tier `stats:` row paired with tombstoned - // raw rows and enqueues one rebuild job per account. Rebuilt days retire - // their daily row, so the pairing predicate converges to zero across - // boots — the retired row is the durable once-per-install marker. - try { - // Self-defers past both the boot window and the retention drain's own - // 600 s stage via its larger constant; no stagger argument threaded. - const { enqueued, skipped, error } = - await enqueueBootTimeDenseIntradayHourlyRebuild(); - if (error) { - workerLog( - "error", - `[dense-intraday-hourly-rebuild] boot discovery failed: ${error}`, - ); - } else { - workerLog( - "info", - `[dense-intraday-hourly-rebuild] boot discovery: enqueued=${enqueued} skipped=${skipped}`, - ); - } - } catch (err) { - workerLog( - "error", - "[dense-intraday-hourly-rebuild] boot discovery threw an unexpected error", - err, - ); - } - - // v1.4.39 — mood rollup tier. Mirrors the v1.4.35.1 - // measurement-rollup pattern: one job per user with mood entries but no - // rollup coverage. Idempotent across reboots and singleton-keyed inside - // pg-boss so a fast restart while a backfill is queued doesn't double up. - try { - // Stage 3. - const { enqueued, skipped, error } = - await enqueueBootTimeMoodRollupBackfill( - BOOT_BACKFILL_STAGGER_SECONDS * 3, - ); - if (error) { - workerLog( - "error", - `[mood-rollup-full-backfill] boot discovery failed: ${error}`, - ); - } else { - workerLog( - "info", - `[mood-rollup-full-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`, - ); - } - } catch (err) { - workerLog( - "error", - "[mood-rollup-full-backfill] boot discovery threw an unexpected error", - err, - ); - } - - // v1.28.37 — step-consolidation repair. Finds every account still holding - // a resurrectable tombstoned GOOGLE_HEALTH / FITBIT daily-total step row - // (swept by the pre-fix consolidation) and enqueues one repair job per - // account. Self-converging: a resurrected row goes live and drops out of - // the discovery predicate. - try { - // Stage 5. - const { enqueued, skipped, error } = - await enqueueBootTimeStepConsolidationRepair( - BOOT_BACKFILL_STAGGER_SECONDS * 5, - ); - if (error) { - workerLog( - "error", - `[step-consolidation-repair] boot discovery failed: ${error}`, - ); - } else { - workerLog( - "info", - `[step-consolidation-repair] boot discovery: enqueued=${enqueued} skipped=${skipped}`, - ); - } - } catch (err) { - workerLog( - "error", - "[step-consolidation-repair] boot discovery threw an unexpected error", - err, - ); - } - - // v1.30.3 — cumulative-PersonalRecord rederivation. Finds every - // account still holding a suspect measurement-driven cumulative-type - // record created before the source-collapse fix landed and enqueues one - // repair job per account. Self-converging: a deleted-and-redetected - // record's `createdAt` sits after the fix cutoff, so the account drops - // out of the discovery predicate for good. - try { - // Stage 6. - const { enqueued, skipped, error } = - await enqueueBootTimeCumulativePrRederivation( - BOOT_BACKFILL_STAGGER_SECONDS * 6, - ); - if (error) { - workerLog( - "error", - `[cumulative-pr-rederive] boot discovery failed: ${error}`, - ); - } else { - workerLog( - "info", - `[cumulative-pr-rederive] boot discovery: enqueued=${enqueued} skipped=${skipped}`, - ); - } - } catch (err) { - workerLog( - "error", - "[cumulative-pr-rederive] boot discovery threw an unexpected error", - err, - ); - } -} diff --git a/src/lib/jobs/reminder/register-status.ts b/src/lib/jobs/reminder/register-status.ts deleted file mode 100644 index 81f241b10..000000000 --- a/src/lib/jobs/reminder/register-status.ts +++ /dev/null @@ -1,878 +0,0 @@ -/** - * Status / insight / computed-score queue registrar. - * - * Owns the nightly status batch (every per-metric status card, one provider - * call per account), the comprehensive-insight - * pre-generation, the on-demand per-metric status generation, the nightly - * computed scores (recovery / stress / strain), the proactive Coach nudge, the - * Coach memory refresh, the period-narrative warm, and the medication - * low-stock pass. - * - * v1.4.37 dead-queue contract: every queue name appears in `allQueues`, its - * cron (where it has one) appears as a `[QUEUE, CRON]` tuple in `schedules`, - * and a `createAndWork(boss, QUEUE, …, handler)` binding drains it. The - * status-wiring guards (`status-cron-candidates`, `insight-pregenerate`, - * `insight-status-generate`, `recovery-score-queue`, - * `stress-strain-retention-queue`, `period-narrative-warm`, - * `coach-memory-refresh-queue`) read THIS module. - */ -import { PgBoss } from "pg-boss"; -import { reportWorkerError } from "@/lib/jobs/report-worker-error"; -import { recordError } from "@/lib/jobs/worker-status"; -import { - INSIGHT_PREGENERATE_QUEUE, - INSIGHT_PREGENERATE_CRON, - type InsightPregeneratePayload, -} from "@/lib/jobs/insight-pregenerate"; -import { - MORNING_DIGEST_REFRESH_QUEUE, - runMorningDigestRefresh, - type MorningDigestRefreshPayload, -} from "@/lib/jobs/morning-digest-refresh"; -import { DATA_ARRIVAL_QUEUE, handleDataArrival } from "@/lib/jobs/data-arrival"; -import { - REACTION_LINE_QUEUE, - handleReactionLine, -} from "@/lib/jobs/reaction-line"; -import type { ReactionLineJob } from "@/lib/arrivals/reaction-line-shared"; -import { - handleWorkoutInsightGenerate, - WORKOUT_INSIGHT_GENERATE_CONCURRENCY, - WORKOUT_INSIGHT_GENERATE_QUEUE, - type WorkoutInsightGeneratePayload, -} from "@/lib/jobs/workout-insight-generate"; -import type { DataArrival } from "@/lib/arrivals/types"; -import { - DAILY_BRIEFING_QUEUE, - DAILY_BRIEFING_CRON, - runDailyBriefingTick, -} from "@/lib/jobs/daily-briefing"; -import { maybeDispatchDailyBriefing } from "@/lib/daily/daily-briefing-push"; -import { - RECOVERY_SCORE_QUEUE, - RECOVERY_SCORE_CRON, - runRecoveryScore, -} from "@/lib/jobs/recovery-score"; -import { - COACH_NUDGE_QUEUE, - COACH_NUDGE_CRON, - runCoachNudgeTick, -} from "@/lib/jobs/coach-nudge"; -import { - COACH_REMINDER_SWEEP_QUEUE, - COACH_REMINDER_SWEEP_CRON, - runCoachReminderSweep, -} from "@/lib/jobs/coach-reminder-sweep"; -import { - COACH_PLAN_REVIEW_QUEUE, - COACH_PLAN_REVIEW_CRON, - runCoachPlanReviewTick, -} from "@/lib/jobs/coach-plan-review"; -import { - MEDICATION_LOW_STOCK_QUEUE, - MEDICATION_LOW_STOCK_CRON, - runMedicationLowStockTick, -} from "@/lib/jobs/medication-low-stock"; -import { - STRESS_SCORE_QUEUE, - STRESS_SCORE_CRON, - runStressScore, -} from "@/lib/jobs/stress-score"; -import { - STRAIN_SCORE_QUEUE, - STRAIN_SCORE_CRON, - runStrainScore, -} from "@/lib/jobs/strain-score"; -import { - PERIOD_NARRATIVE_QUEUE, - PERIOD_NARRATIVE_CRON, - runPeriodNarrativeWarm, - warmOneNarrative, - type PeriodNarrativePayload, -} from "@/lib/jobs/period-narrative-warm"; -import { - COACH_MEMORY_REFRESH_QUEUE, - type CoachMemoryRefreshPayload, -} from "@/lib/ai/coach/coach-memory-shared"; -import { runCoachMemoryRefresh } from "@/lib/ai/coach/coach-memory-refresh-worker"; -import { - INSIGHT_STATUS_GENERATE_QUEUE, - INSIGHT_STATUS_GENERATE_CONCURRENCY, - type InsightStatusGeneratePayload, -} from "@/lib/jobs/insight-status-generate"; -import { withBackgroundEvent } from "@/lib/logging/background"; -import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome"; -import { jobBudget } from "@/lib/jobs/job-budget"; -import { lockedPass, WHOLE_PASS } from "@/lib/jobs/long-pass"; -import { heavyLane } from "@/lib/jobs/heavy-lane"; -import { INSIGHT_PREGENERATE_EXPIRE_SECONDS } from "@/lib/jobs/insight-pregenerate-shared"; -import { PERIOD_NARRATIVE_EXPIRE_SECONDS } from "@/lib/jobs/period-narrative-shared"; -import { getWorkerPrisma, workerLog } from "./shared"; -import { - createAndSchedule, - createAndWork, - insightRetryOptions, - nightlyInsightPassOptions, - retireQueues, - type QueuePolicyTable, - type ScheduleEntry, -} from "./registrar-shared"; -import { - GeneralStatusPayload, - handleGeneralStatusGenerate, - handleInsightPregenerateJob, - handleInsightStatusGenerate, -} from "./insights-handlers"; - -const GENERAL_STATUS_QUEUE = "insights-general-status"; - -const GENERAL_STATUS_CRON = "0 2 * * *"; // daily at 02:00 - -/** - * The six per-metric status crons that followed the 02:00 batch (02:05 to - * 02:30). The batch builds every card and falls each metric it could not - * cover back to that card's own path, so they only re-read cards the batch - * had just written; a user who joins later is warmed by the visit or the next - * night. Their schedules and queues are removed on an instance that still has - * them. - */ -const RETIRED_STATUS_QUEUES = [ - "insights-blood-pressure-status", - "insights-weight-status", - "insights-pulse-status", - "insights-bmi-status", - "insights-mood-status", - "insights-medication-compliance-status", -] as const; - -const allQueues = [ - GENERAL_STATUS_QUEUE, - // v1.7.0 — nightly comprehensive-insight pre-generation so the daily - // briefing is warm before the user opens /insights or the dashboard - // snapshot. Without this entry the 04:30 schedule silently no-ops. - INSIGHT_PREGENERATE_QUEUE, - // v1.8.3 — on-demand per-metric status generation. The read-only status - // route enqueues here on a cold card; without this entry pg-boss never - // provisions the queue and the enqueue silently drops. No cron schedule — - // a send-only queue driven by navigation. - INSIGHT_STATUS_GENERATE_QUEUE, - // Event-driven morning digest refresh, enqueued by the sleep-arrival - // trigger when last night's completed sleep lands. No cron schedule — a - // send-only queue driven by sleep ingest; without this entry pg-boss never - // provisions the queue and every enqueue silently drops. - MORNING_DIGEST_REFRESH_QUEUE, - // v1.31.0 — the data-arrival spine. Every salient ingest emits here; the - // worker claims the day's reaction marker and fans out to the reaction - // surfaces. No cron schedule — a send-only queue driven by ingest; without - // this entry pg-boss never provisions the queue and every emit silently - // drops (the v1.4.37 dead-queue class). - DATA_ARRIVAL_QUEUE, - // v1.31.0 — the day's single arrival reaction line. Send-only, enqueued by - // the spine worker on the pass that claimed the day's marker. Without this - // entry pg-boss never provisions the queue and every enqueue silently drops. - REACTION_LINE_QUEUE, - // v1.31.0 — the per-workout Activity Insight, dispatched by the spine when a - // workout lands. No cron schedule — nothing warms it and no read path - // triggers it, so this entry is the only thing that provisions the queue. - WORKOUT_INSIGHT_GENERATE_QUEUE, - // v1.10.0 — computed scores (WX-C / WX-E). Nightly Recovery / Stress / - // Strain compute + store. The queue MUST be registered here or pg-boss - // never provisions it and the nightly schedule silently never fires. - RECOVERY_SCORE_QUEUE, - STRESS_SCORE_QUEUE, - STRAIN_SCORE_QUEUE, - // v1.11.0 — nightly period-narrative warm + single-user warm enqueued by - // the read-only narrative GET. - PERIOD_NARRATIVE_QUEUE, - // v1.11.1 — combined Coach memory-refresh (rolling conversation summary + - // durable fact extraction), enqueued fire-and-forget from a long chat turn. - COACH_MEMORY_REFRESH_QUEUE, - // v1.15.20 — proactive Coach nudge. Without this entry the daily 05:15 - // schedule silently no-ops and no nudge ever fires. - COACH_NUDGE_QUEUE, - // v1.22 — daily Coach-reminder sweep. Without this entry the 05:20 - // schedule silently no-ops and no "remind me" reminder ever surfaces. - COACH_REMINDER_SWEEP_QUEUE, - // v1.22 — daily n-of-1 experiment review worker. Without this entry - // the 05:25 schedule silently no-ops and no experiment read-back is written. - COACH_PLAN_REVIEW_QUEUE, - // v1.16.11 — daily medication low-stock pass. Without this entry the 09:00 - // schedule silently no-ops and no low-stock alert ever fires. - MEDICATION_LOW_STOCK_QUEUE, - // Daily-briefing fallback cron. The primary morning push rides the - // sleep-arrival finalisation hook below; this queue is the fixed local-morning - // slot for the no-sleep-arrived case. Without this entry the every-15-min - // schedule silently no-ops and the fallback push never fires. - DAILY_BRIEFING_QUEUE, -]; - -const schedules: ScheduleEntry[] = [ - [GENERAL_STATUS_QUEUE, GENERAL_STATUS_CRON, nightlyInsightPassOptions], - // v1.7.0 — nightly 04:30 Europe/Berlin comprehensive-insight - // pre-generation. Budget-gated per user inside the handler. - [ - INSIGHT_PREGENERATE_QUEUE, - INSIGHT_PREGENERATE_CRON, - { - ...insightRetryOptions, - expireInSeconds: INSIGHT_PREGENERATE_EXPIRE_SECONDS, - }, - ], - // v1.10.0 — computed scores (WX-C). Nightly 04:45 Europe/Berlin - // Recovery-score compute + store, after the rollup-feeding consolidation - // + drain so the signals it reads are already folded. - [RECOVERY_SCORE_QUEUE, RECOVERY_SCORE_CRON], - // v1.10.0 — computed scores (WX-E). Nightly 04:50 Europe/Berlin - // Stress-score compute + store, after the dense intra-day retention - // drain so the HRV inputs it reads are settled. - [STRESS_SCORE_QUEUE, STRESS_SCORE_CRON], - // v1.10.0 — computed scores (WX-E). Nightly 04:55 Europe/Berlin - // Strain-score compute + store, after the recovery + stress passes so - // the nightly score writes stay ordered. - [STRAIN_SCORE_QUEUE, STRAIN_SCORE_CRON], - // v1.11.0 — nightly 05:05 Europe/Berlin period-narrative warm. The - // handler only fans out on a week (Mon) / month (1st) boundary; every - // other night is a cheap no-op. Budget-gated per user inside the runner. - [ - PERIOD_NARRATIVE_QUEUE, - PERIOD_NARRATIVE_CRON, - { - ...insightRetryOptions, - expireInSeconds: PERIOD_NARRATIVE_EXPIRE_SECONDS, - }, - ], - // v1.15.20 — daily 05:15 Europe/Berlin proactive Coach nudge, after - // the 04:45–04:55 score crons so the recovery-score trigger reads - // settled rows. Deterministic triggers only — no AI call on this path. - [COACH_NUDGE_QUEUE, COACH_NUDGE_CRON], - // v1.22 — daily 05:20 Europe/Berlin Coach-reminder sweep, just after - // the nudge tick. Flips overdue reminders to `due` + mints plan-review - // reminders from passed CoachPlan.reviewDate. In-app surface only; no push. - [COACH_REMINDER_SWEEP_QUEUE, COACH_REMINDER_SWEEP_CRON], - // v1.22 — daily 05:25 Europe/Berlin n-of-1 experiment review, just - // after the reminder sweep mints the plan-review reminders this worker reads. - // Writes the grounded before/after read-back; no provider call, no push. - [COACH_PLAN_REVIEW_QUEUE, COACH_PLAN_REVIEW_CRON], - // v1.16.11 — medication low-stock pass at 09:00 Europe/Berlin: a - // supply alert is an errand prompt, so it fires at a time the user - // can act on it. Once daily; the per-medication stamp keeps it at - // one push per threshold crossing. - [MEDICATION_LOW_STOCK_QUEUE, MEDICATION_LOW_STOCK_CRON], - // Daily-briefing fallback slot. Ticks every 15 min; the per-user - // local-hour gate inside the tick selects each user's fixed morning slot, so - // one UTC cron serves every timezone. The `push_attempts` cap suppresses it - // when the sleep-arrival finalisation push already fired this morning. - [DAILY_BRIEFING_QUEUE, DAILY_BRIEFING_CRON], -]; - -/** - * De-duplication policy per status queue. - * - * Most queues here are keyless nightly cron ticks — no `singletonKey` on any - * send — so a policy would only constrain the empty key and is deliberately - * omitted. The LLM-bound warm queues (insight pre-generate, per-metric status - * generate, period narrative, Coach memory refresh) already pass an explicit - * `singletonSeconds` on every send, which populates `singleton_on` and is - * therefore constrained by pg-boss's `job_i4` index REGARDLESS of queue policy. - * Those already de-duplicate today; changing their policy would layer a second, - * differently-shaped constraint over a working one for no gain, so they are - * left alone. - * - * That leaves exactly two queues here whose keys would otherwise be inert. - */ -const queuePolicies: QueuePolicyTable = { - // v1.31.0 — the arrival spine's day-scoped keys - // (`arrival:::`) carry the SAME requirement as the - // morning refresh below, for the same reason: a user's local date cannot be - // expressed as a wall-clock `singletonSeconds` window, so the date lives in - // the key and the policy has to honour it. Under `standard` the key - // coalesces nothing at all, and a device posting a batch per minute would - // queue a job per batch instead of one per day. - // - // `exclusive` rather than `short` because the handler is self-converging: - // it claims a durable unique row, so a second run while the first is active - // is pure duplicated work, and the next ingest re-emits anyway. - // - // The `workout` kind is the deliberate exception — it is keyed per workout - // id and DOES pass `singletonSeconds`, so it is constrained by pg-boss's - // `job_i4` index regardless of this policy. Both shapes coexist on one - // queue safely: they populate different columns. - // The reaction line's key is `reaction-line:::` — the - // same day-scoped shape, and for the same reason: a wall-clock window cannot - // express a user's local date. `exclusive` is what makes the key real, and it - // is the queue-level restatement of the durable claim the worker re-checks - // (`generatedAt`) before it spends anything. - [REACTION_LINE_QUEUE]: { - policy: "exclusive", - reason: - "Day-scoped per-kind keys carry the user's local date; under standard they coalesce nothing and a retried spine job could stack a second provider call behind the first.", - }, - [DATA_ARRIVAL_QUEUE]: { - policy: "exclusive", - reason: - "Day-scoped arrival keys carry the user's local date; under standard they coalesce nothing and a chatty ingest queues a job per batch. The worker claims a durable unique row, so a concurrent second run is pure duplicated work.", - }, - // The sleep-arrival trigger has seven write seams that can each enqueue for - // the same user and the same local date. The key is - // `morning-refresh::`, and `exclusive` turns that into a - // real at-most-once-per-user-per-local-morning contract across queued, - // active, and retry-backoff states. - // - // `singletonSeconds` is NOT the tool here and the existing comment at the - // enqueue site says so: its window is a floor() over wall-clock, which does - // not line up with a user's local date, so it would both split one morning - // across two buckets and merge two mornings into one. The local date already - // lives in the key; the policy just has to honour it. - [MORNING_DIGEST_REFRESH_QUEUE]: { - policy: "exclusive", - reason: - "Seven sleep-write seams enqueue the same user+localDate key; exclusive makes the at-most-once-per-morning contract real. A wall-clock singletonSeconds window cannot express a local date.", - }, -}; - -/** - * Register every status / insight / computed-score queue. Returns the queue - * names created (for the boot-level aggregate assertion). - */ -export async function registerStatusQueues( - boss: PgBoss, -): Promise { - await createAndSchedule(boss, allQueues, schedules, queuePolicies); - await retireQueues(boss, RETIRED_STATUS_QUEUES); - - await createAndWork( - boss, - GENERAL_STATUS_QUEUE, - { localConcurrency: 1 }, - lockedPass( - GENERAL_STATUS_QUEUE, - () => WHOLE_PASS, - heavyLane(GENERAL_STATUS_QUEUE, handleGeneralStatusGenerate), - ), - ); - // v1.7.0 — nightly comprehensive-insight pre-generation. - // - // v1.16.8 — localConcurrency 2 (was 1). The queue carries BOTH the - // scheduled 04:30 cohort walk AND the visit-triggered per-user force - // warms; with a single slot a force warm enqueued during the nightly - // batch sat behind the entire cohort and the visiting user stared at - // a cold dashboard for the duration. Two slots let one force warm run - // alongside the batch while still bounding provider-level concurrency - // (the cohort walk is itself sequential per user, and the content-hash - // gate + per-user budget gate inside the handler cover the rare - // double-tick overlap the old single slot serialised away). - await createAndWork( - boss, - INSIGHT_PREGENERATE_QUEUE, - { localConcurrency: 2 }, - // The cohort walk is one pass at a time; a forced warm is one per - // account, so it never waits behind the walk. - lockedPass( - INSIGHT_PREGENERATE_QUEUE, - (job) => - job.data?.force && job.data.userId - ? `user:${job.data.userId}` - : WHOLE_PASS, - heavyLane( - INSIGHT_PREGENERATE_QUEUE, - handleInsightPregenerateJob, - (job) => !(job.data?.force && job.data.userId), - ), - ), - ); - // Event-driven morning digest refresh. One forced comprehensive - // regeneration per user per local morning, enqueued by the sleep-arrival - // trigger. Single-flight: the enqueue-side singletonKey already collapses a - // night's samples into one job, and the handler's marker re-check makes a - // rare double-fire a no-op, so one slot is enough and bounds the provider - // concurrency the same way the nightly path does. - await createAndWork( - boss, - MORNING_DIGEST_REFRESH_QUEUE, - { localConcurrency: 1 }, - async (jobs): Promise => { - return withBackgroundEvent("job.morning_digest_refresh", async (evt) => { - // The counts the batch already decides on: one refresh per job, and - // the finalised subset that reaches the morning push seam. - let refreshed = 0; - let finalised = 0; - for (const job of jobs) { - try { - const result = await runMorningDigestRefresh( - getWorkerPrisma(), - job.data, - ); - refreshed++; - evt.addMeta( - "morning_refresh", - `${result.status}:${result.comprehensive ?? "none"}`, - ); - // The day just finalised (last night's sleep folded in), so - // this is the natural, event-driven moment to fire the calm morning - // push carrying the FINAL digest. Fully fault-isolated inside the - // seam; the opt-in / morning-window / once-per-day gates all live - // there, so a user who never opted in or is outside the window is a - // clean no-op. The fallback cron covers the no-sleep-arrived case. - if (result.status === "finalised") { - finalised++; - const pushResult = await maybeDispatchDailyBriefing( - getWorkerPrisma(), - job.data.userId, - new Date(), - ); - evt.addMeta("daily_briefing_push", pushResult); - } - } catch (err) { - recordError(); - workerLog("error", "[morning-digest-refresh] pass failed", err); - // Rethrow so the queue's retry policy re-runs the refresh; the - // marker re-check keeps a retry idempotent. - throw err; - } - } - return jobDone({ refreshed, finalised }); - }); - }, - ); - // v1.8.3 — on-demand per-metric status generation enqueued by the - // read-only status route on a cold card. Low concurrency so a first - // visit that cold-misses several cards can't saturate the Prisma pool - // or fan out an unbounded number of concurrent provider calls. - await createAndWork( - boss, - INSIGHT_STATUS_GENERATE_QUEUE, - { localConcurrency: INSIGHT_STATUS_GENERATE_CONCURRENCY }, - handleInsightStatusGenerate, - ); - // v1.10.0 — computed scores (WX-C). Nightly Recovery-score compute + - // store. The cron tick carries an empty payload; the runner iterates every - // eligible user and upserts one `COMPUTED RECOVERY_SCORE` row per scored - // day (idempotent — a re-fire overwrites in place). Single-flight so two - // ticks never double-walk the cohort. - await createAndWork( - boss, - RECOVERY_SCORE_QUEUE, - { localConcurrency: 1 }, - async (): Promise => { - try { - const summary = await runRecoveryScore(getWorkerPrisma()); - workerLog( - "info", - `[recovery-score] considered=${summary.considered} stored=${summary.stored} insufficient=${summary.insufficient} errored=${summary.errored}`, - ); - return jobDone({ - considered: summary.considered, - stored: summary.stored, - insufficient: summary.insufficient, - errored: summary.errored, - }); - } catch (err) { - recordError(); - workerLog("error", "[recovery-score] pass failed", err); - throw err; - } - }, - ); - // v1.15.20 — proactive Coach nudge. Single-flight; the push-attempts - // ledger caps a user at one nudge per rolling week, so an overlapping - // tick would only waste reads. Deterministic triggers, no AI call. - await createAndWork( - boss, - COACH_NUDGE_QUEUE, - { localConcurrency: 1 }, - async (): Promise => { - return withBackgroundEvent("job.coach_nudge", async (evt) => { - try { - const summary = await runCoachNudgeTick( - getWorkerPrisma(), - new Date(), - ); - evt.setBackground({ - task_name: "job.coach_nudge", - result: { - candidates_scanned: summary.candidatesScanned, - dispatched: summary.dispatched, - persisted: summary.persisted, - skipped_opted_out: summary.skippedOptedOut, - skipped_no_provider: summary.skippedNoProvider, - skipped_unavailable: summary.skippedUnavailable, - skipped_recent_nudge: summary.skippedRecentNudge, - skipped_recent_engagement: summary.skippedRecentEngagement, - skipped_no_trigger: summary.skippedNoTrigger, - skipped_no_channel: summary.skippedNoChannel, - failed: summary.failed, - }, - }); - // A per-user failure never fails this pass — it rides out as the - // `failed` count, exactly as it does in the wide event. - return jobDone({ - candidates_scanned: summary.candidatesScanned, - dispatched: summary.dispatched, - persisted: summary.persisted, - failed: summary.failed, - }); - } catch (err) { - evt.setError(err); - recordError(); - throw err; - } - }); - }, - ); - // v1.16.11 — medication low-stock pass. Single-flight; the - // per-medication stamp makes a re-fire idempotent (already-notified - // crossings skip), so an overlapping tick would only waste reads. - await createAndWork( - boss, - MEDICATION_LOW_STOCK_QUEUE, - { localConcurrency: 1 }, - async (): Promise => { - return withBackgroundEvent("job.medication_low_stock", async (evt) => { - try { - const summary = await runMedicationLowStockTick( - getWorkerPrisma(), - new Date(), - ); - evt.setBackground({ - task_name: "job.medication_low_stock", - result: { - users_scanned: summary.usersScanned, - skipped_threshold_off: summary.skippedThresholdOff, - medications_evaluated: summary.medicationsEvaluated, - notified: summary.notified, - rearmed: summary.rearmed, - skipped_already_notified: summary.skippedAlreadyNotified, - skipped_above_threshold: summary.skippedAboveThreshold, - skipped_no_runway: summary.skippedNoRunway, - skipped_no_channel: summary.skippedNoChannel, - failed: summary.failed, - }, - }); - // A per-medication failure never fails this pass — it rides out as - // the `failed` count. - return jobDone({ - users_scanned: summary.usersScanned, - medications_evaluated: summary.medicationsEvaluated, - notified: summary.notified, - rearmed: summary.rearmed, - failed: summary.failed, - }); - } catch (err) { - evt.setError(err); - recordError(); - throw err; - } - }); - }, - ); - // v1.31.0 — the data-arrival spine. Single-flight: the worker's whole job is - // to claim a durable unique row, so two slots would only race each other for - // a claim exactly one of them can win. It makes no provider call — a - // module-graph test pins that — so one slot costs nothing in throughput. - await createAndWork( - boss, - DATA_ARRIVAL_QUEUE, - { localConcurrency: 1 }, - handleDataArrival, - ); - // v1.31.0 — the arrival reaction line. Single-flight: this is the spine's - // ONE provider call, and a second concurrent slot would only race the same - // durable `generatedAt` claim exactly one of them can win. - await createAndWork( - boss, - REACTION_LINE_QUEUE, - { localConcurrency: 1 }, - handleReactionLine, - ); - // v1.31.0 — the per-workout Activity Insight. Serial: this IS the provider - // path the spine deliberately does not walk, so it stays off the request pool - // and cannot fan out concurrent completions when several workouts land in one - // sync. - await createAndWork( - boss, - WORKOUT_INSIGHT_GENERATE_QUEUE, - { localConcurrency: WORKOUT_INSIGHT_GENERATE_CONCURRENCY }, - handleWorkoutInsightGenerate, - ); - // Daily-briefing fallback slot. Single-flight; the `push_attempts` - // frequency cap makes an overlapping tick a no-op (a second same-day attempt - // is suppressed), and the tick only loads a digest for opted-in users at - // their exact local fallback hour. No provider call — it reads the cached - // digest and dispatches the deterministic line + cached lead. - await createAndWork( - boss, - DAILY_BRIEFING_QUEUE, - { localConcurrency: 1 }, - async (): Promise => { - return withBackgroundEvent("job.daily_briefing", async (evt) => { - try { - const summary = await runDailyBriefingTick( - getWorkerPrisma(), - new Date(), - ); - evt.setBackground({ - task_name: "job.daily_briefing", - result: { - candidates_scanned: summary.candidatesScanned, - in_slot: summary.inSlot, - sent: summary.sent, - suppressed_frequency: summary.suppressedFrequency, - no_digest: summary.noDigest, - opted_out: summary.optedOut, - no_channel: summary.noChannel, - outside_window: summary.outsideWindow, - failed: summary.failed, - }, - }); - // Most ticks land outside every user's slot; that is the tick doing - // its job, so a zero `sent` is a success with zero counts. - return jobDone({ - candidates_scanned: summary.candidatesScanned, - in_slot: summary.inSlot, - sent: summary.sent, - failed: summary.failed, - }); - } catch (err) { - evt.setError(err); - recordError(); - throw err; - } - }); - }, - ); - // v1.10.0 — computed scores (WX-E). Nightly Stress-score (HRV-derived - // proxy) compute + store. Single-flight so two ticks never double-walk - // the cohort. The runner iterates every eligible user and upserts one - // `COMPUTED STRESS_SCORE` row per scored day (idempotent — a re-fire - // overwrites in place). - await createAndWork( - boss, - STRESS_SCORE_QUEUE, - { localConcurrency: 1 }, - async (): Promise => { - try { - const summary = await runStressScore(getWorkerPrisma()); - workerLog( - "info", - `[stress-score] considered=${summary.considered} stored=${summary.stored} insufficient=${summary.insufficient} errored=${summary.errored}`, - ); - return jobDone({ - considered: summary.considered, - stored: summary.stored, - insufficient: summary.insufficient, - errored: summary.errored, - }); - } catch (err) { - recordError(); - workerLog("error", "[stress-score] pass failed", err); - throw err; - } - }, - ); - // v1.10.0 — computed scores (WX-E). Nightly Strain-score (Banister TRIMP - // cardio-load) compute + store. Single-flight; upserts one `COMPUTED - // STRAIN_SCORE` row per scored day (idempotent). - await createAndWork( - boss, - STRAIN_SCORE_QUEUE, - { localConcurrency: 1 }, - async (): Promise => { - try { - const summary = await runStrainScore(getWorkerPrisma()); - workerLog( - "info", - `[strain-score] considered=${summary.considered} stored=${summary.stored} insufficient=${summary.insufficient} errored=${summary.errored}`, - ); - return jobDone({ - considered: summary.considered, - stored: summary.stored, - insufficient: summary.insufficient, - errored: summary.errored, - }); - } catch (err) { - recordError(); - workerLog("error", "[strain-score] pass failed", err); - throw err; - } - }, - ); - // v1.11.0 — period-narrative warm. A scheduled tick (no `userId`) runs the - // boundary-gated nightly fan-out; a `userId` payload runs a single-user warm - // enqueued by the read-only GET on a cold/stale read. Single-flight so two - // ticks never double-walk the cohort; the per-user budget gate covers the - // fan-out and the enqueue `singletonKey` covers the single-user path. - await createAndWork( - boss, - PERIOD_NARRATIVE_QUEUE, - { localConcurrency: 1 }, - lockedPass( - PERIOD_NARRATIVE_QUEUE, - (job) => (job.data?.userId ? `user:${job.data.userId}` : WHOLE_PASS), - heavyLane( - PERIOD_NARRATIVE_QUEUE, - async (jobs): Promise => { - // Two modes on one queue, so the outcome counts them separately: a - // non-boundary night is a scheduled pass that generated nothing, which - // is a success with zero counts, not an idle queue. - let singleUser = 0; - let scheduled = 0; - let generated = 0; - let failed = 0; - let stoppedEarly = false; - for (const job of jobs) { - try { - if (job.data?.userId) { - await warmOneNarrative(job.data); - singleUser++; - } else { - const summary = await runPeriodNarrativeWarm( - getWorkerPrisma(), - { - shouldStop: jobBudget(jobs), - }, - ); - scheduled++; - generated += summary.generated; - failed += summary.failed; - stoppedEarly ||= summary.stoppedEarly; - workerLog( - "info", - `[period-narrative] periods=${summary.periods.join(",") || "none"} total=${summary.total} generated=${summary.generated} cached=${summary.cached} skipped=${summary.skipped} insufficient=${summary.insufficient} failed=${summary.failed} budget=${summary.budgetBlocked}`, - ); - } - } catch (err) { - recordError(); - await reportWorkerError(PERIOD_NARRATIVE_QUEUE, err, { - mode: job.data?.userId ? "single-user" : "scheduled", - }); - throw err; - } - } - return jobDone({ - single_user: singleUser, - scheduled, - generated, - failed, - stopped_early: stoppedEarly, - }); - }, - (job) => !job.data?.userId, - ), - ), - ); - // v1.11.1 — combined Coach memory refresh: rolling conversation summary + - // durable fact extraction for one long conversation. localConcurrency 1 so a - // burst of long-conversation turns can't fan out concurrent provider calls; - // each step is budget-gated inside runStatusCompletion and fault-isolated. - await createAndWork( - boss, - COACH_MEMORY_REFRESH_QUEUE, - { localConcurrency: 1 }, - async (jobs): Promise => { - // An incomplete payload is skipped rather than failed, so it has to be - // countable — otherwise a queue full of them reads as a clean run. - let refreshed = 0; - let skippedIncomplete = 0; - for (const job of jobs) { - if (!job.data?.conversationId || !job.data?.userId) { - skippedIncomplete++; - continue; - } - try { - await runCoachMemoryRefresh(job.data); - refreshed++; - } catch (err) { - recordError(); - workerLog("error", "[coach-memory-refresh] failed", err); - throw err; - } - } - return jobDone({ - refreshed, - skipped_incomplete: skippedIncomplete, - }); - }, - ); - - // v1.22 — daily Coach-reminder sweep. Single-flight; surfacing an - // overdue reminder into the user's Coach thread and minting plan-review - // reminders are both idempotent across re-fires (an already-surfaced - // reminder is not re-written; a plan whose reviewDate was cleared is not - // re-minted). No provider call, no push — the message body is a - // deterministic localized template around the user's own note. - await createAndWork( - boss, - COACH_REMINDER_SWEEP_QUEUE, - { localConcurrency: 1 }, - async (): Promise => { - return withBackgroundEvent("job.coach_reminder_sweep", async (evt) => { - try { - const summary = await runCoachReminderSweep( - getWorkerPrisma(), - new Date(), - ); - evt.setBackground({ - task_name: "job.coach_reminder_sweep", - result: { - reminders_due: summary.remindersDue, - plan_reviews_minted: summary.planReviewsMinted, - context_surfaced: summary.contextSurfaced, - nag_dismissed: summary.nagDismissed, - errored: summary.errored, - }, - }); - return jobDone({ - reminders_due: summary.remindersDue, - plan_reviews_minted: summary.planReviewsMinted, - context_surfaced: summary.contextSurfaced, - nag_dismissed: summary.nagDismissed, - errored: summary.errored, - }); - } catch (err) { - evt.setError(err); - recordError(); - throw err; - } - }); - }, - ); - - // v1.22 — n-of-1 experiment review worker: reads the plan-review - // reminders the sweep minted and writes the grounded before/after read-back - // into the plan's encrypted outcome column, flipping it to `reviewed`. - // Idempotent (a reviewed plan is not re-processed). No provider call, no push. - await createAndWork( - boss, - COACH_PLAN_REVIEW_QUEUE, - { localConcurrency: 1 }, - async (): Promise => { - return withBackgroundEvent("job.coach_plan_review", async (evt) => { - try { - const summary = await runCoachPlanReviewTick( - getWorkerPrisma(), - new Date(), - ); - evt.setBackground({ - task_name: "job.coach_plan_review", - result: { - reviewed: summary.reviewed, - insufficient: summary.insufficient, - errored: summary.errored, - }, - }); - return jobDone({ - reviewed: summary.reviewed, - insufficient: summary.insufficient, - errored: summary.errored, - }); - } catch (err) { - evt.setError(err); - recordError(); - throw err; - } - }); - }, - ); - - return allQueues; -} diff --git a/src/lib/jobs/reminder/rollup-handlers.ts b/src/lib/jobs/reminder/rollup-handlers.ts new file mode 100644 index 000000000..74c765d02 --- /dev/null +++ b/src/lib/jobs/reminder/rollup-handlers.ts @@ -0,0 +1,582 @@ +/** + * Handlers of the rollup and consolidation queues: the measurement and mood + * rollup folds, the consolidation and repair passes, the supplement nutrient + * recompute, and the nightly drain that runs the cumulative, daily-mean and + * dense-retention passes on one tick. The registry binds them + * (`registry/rollup.ts`) and loads this module on the first job. + */ +import type { Job, PgBoss } from "pg-boss"; + +import { + DRAIN_CONTINUATION_DELAY_SECONDS, + nextDrainContinuation, +} from "@/lib/jobs/drain-continuation"; +import { + DRAIN_CUMULATIVE_QUEUE, + type DrainCumulativePayload, +} from "@/lib/jobs/drain-cumulative-queue"; +import { runDenseIntradayHourlyRebuildForUser } from "@/lib/jobs/dense-intraday-hourly-rebuild"; +import type { DenseIntradayHourlyRebuildPayload } from "@/lib/jobs/dense-intraday-hourly-rebuild"; +import { + DENSE_INTRADAY_RETENTION_ENABLED, + runDenseIntradayRetentionForUser, + type DenseIntradayRetentionPayload, +} from "@/lib/jobs/dense-intraday-retention"; +import { jobBudget } from "@/lib/jobs/job-budget"; +import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome"; +import { + runMeanConsolidationForUser, + type MeanConsolidationPayload, +} from "@/lib/jobs/mean-consolidation"; +import { reportWorkerError } from "@/lib/jobs/report-worker-error"; +import { + runStepConsolidationForUser, + type StepConsolidationPayload, +} from "@/lib/jobs/step-consolidation"; +import { + runStepConsolidationRepairForUser, + type StepConsolidationRepairPayload, +} from "@/lib/jobs/step-consolidation-repair"; +import { recordError } from "@/lib/jobs/worker-status"; +import { + consolidateDailyMean, + MEAN_CONSOLIDATION_CUTOFF_HOURS, +} from "@/lib/measurements/consolidate-daily-mean"; +import { runDenseIntradayRetention } from "@/lib/measurements/dense-intraday-retention"; +import { + drainPerSampleCumulative, + DRAIN_CUMULATIVE_CUTOFF_HOURS, +} from "@/lib/measurements/drain-per-sample-cumulative"; +import { + runSupplementNutrientRecompute, + type SupplementNutrientRecomputePayload, +} from "@/lib/medications/ingredients/recompute-job"; +import { + runCumulativePrRederivationForUser, + type CumulativePrRederivePayload, +} from "@/lib/personal-records/cumulative-pr-rederivation"; +import { + recomputeUserRollups, + type RollupFullBackfillPayload, + type RollupRecomputePayload, +} from "@/lib/rollups/measurement-rollups"; +import { + recomputeUserMoodRollups, + type MoodRollupFullBackfillPayload, + type MoodRollupRecomputePayload, +} from "@/lib/rollups/mood-rollups"; + +import { cronIsTheRetry } from "./registrar-shared"; +import { getWorkerPrisma, workerLog } from "./shared"; + +/** + * Folds the WEEK / MONTH / YEAR buckets the write-path hooks enqueue; the DAY + * bucket is recomputed by the hook itself. No per-bucket isolation on + * purpose: a recompute that throws fails the job, so reaching the end means + * every bucket in the batch was folded. + */ +export async function handleRollupRecompute( + jobs: Job[], +): Promise { + for (const job of jobs) { + const payload = job.data; + await recomputeUserRollups(payload.userId, { + types: [payload.type], + granularities: [payload.granularity], + from: new Date(payload.from), + to: new Date(payload.to), + }); + } + return jobDone({ buckets_recomputed: jobs.length }); +} + +/** + * The full fold of one uncovered account across the default window and every + * granularity. Each granularity commits on its own, so a run stopped on its + * budget throws and the retry folds the rest. + */ +export async function handleRollupFullBackfill( + jobs: Job[], +): Promise { + const shouldStop = jobBudget(jobs); + let rowsUpsertedTotal = 0; + for (const job of jobs) { + const { userId } = job.data; + const { rowsUpserted, durationMs, stopped } = await recomputeUserRollups( + userId, + { shouldStop }, + ); + if (stopped) { + throw new Error( + `rollup-full-backfill for user ${userId} stopped on its job budget; the retry folds the rest`, + ); + } + rowsUpsertedTotal += rowsUpserted; + workerLog( + "info", + `[rollup-full-backfill] user=${userId} rows=${rowsUpserted} duration=${durationMs}ms`, + ); + } + return jobDone({ + users: jobs.length, + rows_upserted: rowsUpsertedTotal, + }); +} + +/** Collapses legacy granular step rows into one daily total per day. */ +export async function handleStepConsolidation( + jobs: Job[], +): Promise { + let daysTotal = 0; + let legacyRowsTotal = 0; + for (const job of jobs) { + const { userId } = job.data; + const { daysConsolidated, legacyRowsSoftDeleted } = + await runStepConsolidationForUser(userId); + daysTotal += daysConsolidated; + legacyRowsTotal += legacyRowsSoftDeleted; + workerLog( + "info", + `[step-consolidation] user=${userId} days=${daysConsolidated} legacyRowsSoftDeleted=${legacyRowsSoftDeleted}`, + ); + } + return jobDone({ + users: jobs.length, + days_consolidated: daysTotal, + legacy_rows_soft_deleted: legacyRowsTotal, + }); +} + +/** + * Resurrects the provider daily-total step rows the earlier consolidation + * swept and removes the shadow manual totals it minted. Per-day failures are + * the repair's own and ride out as a count. + */ +export async function handleStepConsolidationRepair( + jobs: Job[], +): Promise { + let rowsResurrected = 0; + let wedgeSkipped = 0; + let manualMintsRemoved = 0; + let daysRecomputed = 0; + let failures = 0; + for (const job of jobs) { + const { userId } = job.data; + const summary = await runStepConsolidationRepairForUser(userId); + rowsResurrected += summary.rowsResurrected; + wedgeSkipped += summary.wedgeSkipped; + manualMintsRemoved += summary.manualMintsRemoved; + daysRecomputed += summary.daysRecomputed; + failures += summary.failures; + workerLog( + "info", + `[step-consolidation-repair] user=${userId} resurrected=${summary.rowsResurrected} wedgeSkipped=${summary.wedgeSkipped} manualMintsRemoved=${summary.manualMintsRemoved} daysRecomputed=${summary.daysRecomputed} failures=${summary.failures}`, + ); + } + return jobDone({ + users: jobs.length, + rows_resurrected: rowsResurrected, + wedge_skipped: wedgeSkipped, + manual_mints_removed: manualMintsRemoved, + days_recomputed: daysRecomputed, + failures, + }); +} + +/** One account's supplement doses recomputed into the day's micronutrients. */ +export async function handleSupplementNutrientRecompute( + jobs: Job[], +): Promise { + let rowsWritten = 0; + for (const job of jobs) { + const result = await runSupplementNutrientRecompute(job.data); + rowsWritten += result.rowsWritten; + } + return jobDone({ users: jobs.length, rows_written: rowsWritten }); +} + +/** + * Deletes an inflated cumulative-type personal record and re-runs detection + * silently, so the honestly derived best takes its place. + */ +export async function handleCumulativePrRederive( + jobs: Job[], +): Promise { + let rowsDeleted = 0; + let rowsReinserted = 0; + for (const job of jobs) { + const { userId } = job.data; + const summary = await runCumulativePrRederivationForUser(userId); + rowsDeleted += summary.rowsDeleted; + rowsReinserted += summary.rowsReinserted; + workerLog( + "info", + `[cumulative-pr-rederive] user=${userId} rowsDeleted=${summary.rowsDeleted} rowsReinserted=${summary.rowsReinserted}`, + ); + } + return jobDone({ + users: jobs.length, + rows_deleted: rowsDeleted, + rows_reinserted: rowsReinserted, + }); +} + +/** + * Collapses each completed day of per-sample high-frequency mean rows to its + * mean. Out of budget is a completed run: the folded days are committed and + * the nightly tick carries on from the first day this run did not reach. + */ +export async function handleMeanConsolidation( + jobs: Job[], +): Promise { + const shouldStop = jobBudget(jobs); + let daysTotal = 0; + let perSampleRowsTotal = 0; + let stoppedEarly = false; + for (const job of jobs) { + const { userId } = job.data; + const summary = await runMeanConsolidationForUser(userId, shouldStop); + const { daysConsolidated, perSampleRowsSoftDeleted } = summary; + stoppedEarly ||= summary.stoppedEarly; + daysTotal += daysConsolidated; + perSampleRowsTotal += perSampleRowsSoftDeleted; + workerLog( + "info", + `[mean-consolidation] user=${userId} days=${daysConsolidated} perSampleRowsSoftDeleted=${perSampleRowsSoftDeleted}`, + ); + } + return jobDone({ + users: jobs.length, + days_consolidated: daysTotal, + per_sample_rows_soft_deleted: perSampleRowsTotal, + stopped_early: stoppedEarly, + }); +} + +/** + * Folds one account's dense-tier (HRV / HR / SpO2) samples older than the + * retention window to hourly means, keeping the in-window intra-day shape the + * Stress engine reads. Out of budget completes the job; the nightly tick + * continues from the first day this run did not reach. + */ +export async function handleDenseIntradayRetention( + jobs: Job[], +): Promise { + const shouldStop = jobBudget(jobs); + let daysTotal = 0; + let perSampleRowsTotal = 0; + let derivedRestingRowsTotal = 0; + let stoppedEarly = false; + for (const job of jobs) { + const { userId } = job.data; + try { + const summary = await runDenseIntradayRetentionForUser( + userId, + shouldStop, + ); + const { + daysConsolidated, + perSampleRowsSoftDeleted, + derivedRestingRowsUpserted, + } = summary; + stoppedEarly ||= summary.stoppedEarly; + daysTotal += daysConsolidated; + perSampleRowsTotal += perSampleRowsSoftDeleted; + derivedRestingRowsTotal += derivedRestingRowsUpserted; + workerLog( + "info", + `[dense-intraday-retention] user=${userId} days=${daysConsolidated} perSampleRowsSoftDeleted=${perSampleRowsSoftDeleted} derivedRestingRowsUpserted=${derivedRestingRowsUpserted}`, + ); + } catch (err) { + recordError(); + workerLog( + "error", + `[dense-intraday-retention] user=${userId} failed`, + err, + ); + throw err; + } + } + return jobDone({ + users: jobs.length, + days_consolidated: daysTotal, + per_sample_rows_soft_deleted: perSampleRowsTotal, + derived_resting_rows_upserted: derivedRestingRowsTotal, + stopped_early: stoppedEarly, + }); +} + +/** + * Reconstructs hourly means from the tombstones behind a pre-hourly daily row + * and retires the daily row. Out of budget fails the job so pg-boss retries + * (the boot send allows three), and the retry starts at the first day not yet + * rebuilt; the queue is `exclusive`, so it cannot hand itself on while active. + */ +export async function handleDenseIntradayHourlyRebuild( + jobs: Job[], +): Promise { + const shouldStop = jobBudget(jobs); + let stoppedEarly = false; + let daysRebuiltTotal = 0; + let hourlyRowsTotal = 0; + let dailyRowsRetiredTotal = 0; + let daysSkippedTotal = 0; + for (const job of jobs) { + const { userId } = job.data; + try { + const summary = await runDenseIntradayHourlyRebuildForUser( + userId, + shouldStop, + ); + const { + daysRebuilt, + hourlyRowsUpserted, + dailyRowsRetired, + daysSkippedNoTombstones, + } = summary; + stoppedEarly ||= summary.stoppedEarly; + daysRebuiltTotal += daysRebuilt; + hourlyRowsTotal += hourlyRowsUpserted; + dailyRowsRetiredTotal += dailyRowsRetired; + daysSkippedTotal += daysSkippedNoTombstones; + workerLog( + "info", + `[dense-intraday-hourly-rebuild] user=${userId} daysRebuilt=${daysRebuilt} hourlyRowsUpserted=${hourlyRowsUpserted} dailyRowsRetired=${dailyRowsRetired} daysSkippedNoTombstones=${daysSkippedNoTombstones}`, + ); + } catch (err) { + recordError(); + workerLog( + "error", + `[dense-intraday-hourly-rebuild] user=${userId} failed`, + err, + ); + throw err; + } + } + const did = { + users: jobs.length, + stopped_early: stoppedEarly, + days_rebuilt: daysRebuiltTotal, + hourly_rows_upserted: hourlyRowsTotal, + daily_rows_retired: dailyRowsRetiredTotal, + days_skipped_no_tombstones: daysSkippedTotal, + }; + if (stoppedEarly) { + return jobFailed( + "stopped before every day was rebuilt; the retry resumes", + undefined, + did, + ); + } + return jobDone(did); +} + +/** Folds the WEEK / MONTH / YEAR mood buckets the mood-entry hooks enqueue. */ +export async function handleMoodRollupRecompute( + jobs: Job[], +): Promise { + for (const job of jobs) { + const payload = job.data; + await recomputeUserMoodRollups(payload.userId, { + granularities: [payload.granularity], + from: new Date(payload.from), + to: new Date(payload.to), + }); + } + return jobDone({ buckets_recomputed: jobs.length }); +} + +/** + * The full mood fold of one account. Delete-then-refold, so the backfill + * cleans up any row minted under an older keying; the brief empty window + * reads correctly through the live fallback. + */ +export async function handleMoodRollupFullBackfill( + jobs: Job[], +): Promise { + let rowsUpsertedTotal = 0; + for (const job of jobs) { + const { userId } = job.data; + try { + const { rowsUpserted, durationMs } = await recomputeUserMoodRollups( + userId, + { replace: true }, + ); + rowsUpsertedTotal += rowsUpserted; + workerLog( + "info", + `[mood-rollup-full-backfill] user=${userId} rows=${rowsUpserted} duration=${durationMs}ms`, + ); + } catch (err) { + recordError(); + workerLog( + "error", + `[mood-rollup-full-backfill] user=${userId} failed`, + err, + ); + throw err; + } + } + return jobDone({ + users: jobs.length, + rows_upserted: rowsUpsertedTotal, + }); +} + +/** + * The nightly drain: the cumulative (SUM) drain, the daily-mean drain and the + * dense intra-day retention drain, in turn, on one budget. They are + * independent passes, so one failing does not stop the others; the first + * failure is reported once the tick is over and fails the job. A tick that ran + * out of budget hands the rest to a bounded follow-up. + */ +export function drainCumulativeHandler( + boss: PgBoss, +): (jobs: Job[]) => Promise { + return async (jobs) => { + const shouldStop = jobBudget(jobs); + let stoppedEarly = false; + let firstFailure: { reason: string; cause: unknown } | undefined; + let passesFailed = 0; + let bucketsCollapsed = 0; + let perSampleRowsDeleted = 0; + let meanDaysConsolidated = 0; + let meanRowsSoftDeleted = 0; + let denseDaysConsolidated = 0; + let denseRowsSoftDeleted = 0; + let denseSkipped = false; + + for (const job of jobs) { + try { + const summary = await drainPerSampleCumulative(getWorkerPrisma(), { + dryRun: false, + cutoffHours: DRAIN_CUMULATIVE_CUTOFF_HOURS, + shouldStop, + log: (line) => workerLog("info", line), + }); + stoppedEarly ||= summary.stoppedEarly; + bucketsCollapsed += summary.totals.bucketsCollapsed; + perSampleRowsDeleted += summary.totals.perSampleRowsDeleted; + workerLog( + "info", + `[drain-cumulative] triggeredAt=${job.data.triggeredAt} usersScanned=${summary.totals.usersScanned} bucketsCollapsed=${summary.totals.bucketsCollapsed} perSampleRowsDeleted=${summary.totals.perSampleRowsDeleted} dailyRowsUpserted=${summary.totals.dailyRowsUpserted}`, + ); + } catch (err) { + recordError(); + await reportWorkerError("drain-cumulative", err); + passesFailed++; + firstFailure ??= { reason: "cumulative drain failed", cause: err }; + } + + // The daily-mean drain rides the same tick, so spot samples between + // reboots do not accumulate raw; the 36-hour grace keeps today's + // in-flight watch syncs raw. + try { + const meanSummary = await consolidateDailyMean(getWorkerPrisma(), { + dryRun: false, + cutoffHours: MEAN_CONSOLIDATION_CUTOFF_HOURS, + shouldStop, + log: (line) => workerLog("info", line), + }); + stoppedEarly ||= meanSummary.stoppedEarly; + meanDaysConsolidated += meanSummary.totals.daysConsolidated; + meanRowsSoftDeleted += meanSummary.totals.perSampleRowsSoftDeleted; + workerLog( + "info", + `[mean-consolidation] triggeredAt=${job.data.triggeredAt} usersScanned=${meanSummary.totals.usersScanned} daysConsolidated=${meanSummary.totals.daysConsolidated} perSampleRowsSoftDeleted=${meanSummary.totals.perSampleRowsSoftDeleted} dailyRowsUpserted=${meanSummary.totals.dailyRowsUpserted} daysFailed=${meanSummary.totals.daysFailed}`, + ); + } catch (err) { + recordError(); + await reportWorkerError("mean-consolidation", err, { + tick: "nightly", + }); + passesFailed++; + firstFailure ??= { reason: "mean consolidation failed", cause: err }; + } + + // The dense intra-day retention drain: HRV and pulse only, keeping the + // last DENSE_INTRADAY_RETENTION_DAYS raw for the Stress engine. These + // types are never in the destructive daily-mean allowlist. + try { + if (!DENSE_INTRADAY_RETENTION_ENABLED) { + denseSkipped = true; + workerLog( + "info", + "[dense-intraday-retention] disabled via DENSE_INTRADAY_RETENTION_ENABLED — skipping the nightly walk (operator kill-switch)", + ); + } else { + const denseSummary = await runDenseIntradayRetention( + getWorkerPrisma(), + { + dryRun: false, + shouldStop, + log: (line) => workerLog("info", line), + }, + ); + stoppedEarly ||= denseSummary.stoppedEarly; + denseDaysConsolidated += denseSummary.totals.daysConsolidated; + denseRowsSoftDeleted += denseSummary.totals.perSampleRowsSoftDeleted; + workerLog( + "info", + `[dense-intraday-retention] triggeredAt=${job.data.triggeredAt} usersScanned=${denseSummary.totals.usersScanned} daysConsolidated=${denseSummary.totals.daysConsolidated} perSampleRowsSoftDeleted=${denseSummary.totals.perSampleRowsSoftDeleted} hourlyRowsUpserted=${denseSummary.totals.hourlyRowsUpserted} dailyRowsRetired=${denseSummary.totals.dailyRowsRetired}`, + ); + } + } catch (err) { + recordError(); + await reportWorkerError("dense-intraday-retention", err, { + tick: "nightly", + }); + passesFailed++; + firstFailure ??= { reason: "dense retention failed", cause: err }; + } + } + + const continuation = Math.max( + 0, + ...jobs.map((job) => job.data.continuation ?? 0), + ); + const next = nextDrainContinuation({ + stoppedEarly, + daysFolded: + bucketsCollapsed + meanDaysConsolidated + denseDaysConsolidated, + continuation, + }); + let continued = false; + if (next !== null) { + try { + const followUp: DrainCumulativePayload = { + triggeredAt: new Date().toISOString(), + continuation: next, + }; + continued = + (await boss.send(DRAIN_CUMULATIVE_QUEUE, followUp, { + ...cronIsTheRetry, + startAfter: DRAIN_CONTINUATION_DELAY_SECONDS, + })) !== null; + } catch (err) { + // The next nightly tick picks the work up regardless; a lost + // follow-up only delays it. + workerLog("error", "[drain-cumulative] follow-up send failed", err); + } + } + + const did = { + stopped_early: stoppedEarly, + continuation, + continued, + passes_failed: passesFailed, + buckets_collapsed: bucketsCollapsed, + per_sample_rows_deleted: perSampleRowsDeleted, + mean_days_consolidated: meanDaysConsolidated, + mean_rows_soft_deleted: meanRowsSoftDeleted, + dense_days_consolidated: denseDaysConsolidated, + dense_rows_soft_deleted: denseRowsSoftDeleted, + dense_skipped: denseSkipped, + }; + + if (firstFailure) { + return jobFailed(firstFailure.reason, firstFailure.cause, did); + } + return jobDone(did); + }; +} diff --git a/src/lib/jobs/reminder/status-handlers.ts b/src/lib/jobs/reminder/status-handlers.ts new file mode 100644 index 000000000..501bf2601 --- /dev/null +++ b/src/lib/jobs/reminder/status-handlers.ts @@ -0,0 +1,394 @@ +/** + * Handlers of the status, score and Coach queues that are not a module's own + * export: the morning digest refresh, the nightly computed scores, the Coach + * ticks, the low-stock pass, the daily-briefing fallback, the period-narrative + * warm and the Coach memory refresh. The registry binds them + * (`registry/status.ts`) and loads this module on the first job. + */ +import type { Job } from "pg-boss"; + +import type { CoachMemoryRefreshPayload } from "@/lib/ai/coach/coach-memory-shared"; +import { runCoachMemoryRefresh } from "@/lib/ai/coach/coach-memory-refresh-worker"; +import { maybeDispatchDailyBriefing } from "@/lib/daily/daily-briefing-push"; +import { runCoachNudgeTick } from "@/lib/jobs/coach-nudge"; +import { runCoachPlanReviewTick } from "@/lib/jobs/coach-plan-review"; +import { runCoachReminderSweep } from "@/lib/jobs/coach-reminder-sweep"; +import { runDailyBriefingTick } from "@/lib/jobs/daily-briefing"; +import { jobBudget } from "@/lib/jobs/job-budget"; +import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome"; +import { runMedicationLowStockTick } from "@/lib/jobs/medication-low-stock"; +import { + runMorningDigestRefresh, + type MorningDigestRefreshPayload, +} from "@/lib/jobs/morning-digest-refresh"; +import { + runPeriodNarrativeWarm, + warmOneNarrative, + type PeriodNarrativePayload, +} from "@/lib/jobs/period-narrative-warm"; +import { PERIOD_NARRATIVE_QUEUE } from "@/lib/jobs/period-narrative-queue"; +import { runRecoveryScore } from "@/lib/jobs/recovery-score"; +import { reportWorkerError } from "@/lib/jobs/report-worker-error"; +import { runStrainScore } from "@/lib/jobs/strain-score"; +import { runStressScore } from "@/lib/jobs/stress-score"; +import { recordError } from "@/lib/jobs/worker-status"; +import { withBackgroundEvent } from "@/lib/logging/background"; + +import { getWorkerPrisma, workerLog } from "./shared"; + +/** + * One forced comprehensive regeneration per user per local morning, enqueued + * by the sleep-arrival trigger. A finalised day is the event-driven moment for + * the morning push carrying the final digest; the push seam owns its own + * opt-in, window and once-per-day gates, and the fallback cron covers the + * mornings no sleep arrives. + */ +export async function handleMorningDigestRefresh( + jobs: Job[], +): Promise { + return withBackgroundEvent("job.morning_digest_refresh", async (evt) => { + // The counts the batch already decides on: one refresh per job, and + // the finalised subset that reaches the morning push seam. + let refreshed = 0; + let finalised = 0; + for (const job of jobs) { + try { + const result = await runMorningDigestRefresh( + getWorkerPrisma(), + job.data, + ); + refreshed++; + evt.addMeta( + "morning_refresh", + `${result.status}:${result.comprehensive ?? "none"}`, + ); + if (result.status === "finalised") { + finalised++; + const pushResult = await maybeDispatchDailyBriefing( + getWorkerPrisma(), + job.data.userId, + new Date(), + ); + evt.addMeta("daily_briefing_push", pushResult); + } + } catch (err) { + recordError(); + workerLog("error", "[morning-digest-refresh] pass failed", err); + // Rethrow so the queue's retry policy re-runs the refresh; the + // marker re-check keeps a retry idempotent. + throw err; + } + } + return jobDone({ refreshed, finalised }); + }); +} + +interface ScoreSummary { + considered: number; + stored: number; + insufficient: number; + errored: number; +} + +/** + * A nightly computed-score pass: the cron tick carries an empty payload and + * the runner walks every eligible user, upserting one `COMPUTED` row per + * scored day (a re-fire overwrites in place). + */ +function scorePass( + name: string, + run: () => Promise, +): () => Promise { + return async () => { + try { + const summary = await run(); + workerLog( + "info", + `[${name}] considered=${summary.considered} stored=${summary.stored} insufficient=${summary.insufficient} errored=${summary.errored}`, + ); + return jobDone({ + considered: summary.considered, + stored: summary.stored, + insufficient: summary.insufficient, + errored: summary.errored, + }); + } catch (err) { + recordError(); + workerLog("error", `[${name}] pass failed`, err); + throw err; + } + }; +} + +export const handleRecoveryScore = scorePass("recovery-score", () => + runRecoveryScore(getWorkerPrisma()), +); +export const handleStressScore = scorePass("stress-score", () => + runStressScore(getWorkerPrisma()), +); +export const handleStrainScore = scorePass("strain-score", () => + runStrainScore(getWorkerPrisma()), +); + +/** + * The proactive Coach nudge. Deterministic triggers, no AI call; a per-user + * failure never fails the pass, it rides out as the `failed` count. + */ +export async function handleCoachNudge(): Promise { + return withBackgroundEvent("job.coach_nudge", async (evt) => { + try { + const summary = await runCoachNudgeTick(getWorkerPrisma(), new Date()); + evt.setBackground({ + task_name: "job.coach_nudge", + result: { + candidates_scanned: summary.candidatesScanned, + dispatched: summary.dispatched, + persisted: summary.persisted, + skipped_opted_out: summary.skippedOptedOut, + skipped_no_provider: summary.skippedNoProvider, + skipped_unavailable: summary.skippedUnavailable, + skipped_recent_nudge: summary.skippedRecentNudge, + skipped_recent_engagement: summary.skippedRecentEngagement, + skipped_no_trigger: summary.skippedNoTrigger, + skipped_no_channel: summary.skippedNoChannel, + failed: summary.failed, + }, + }); + return jobDone({ + candidates_scanned: summary.candidatesScanned, + dispatched: summary.dispatched, + persisted: summary.persisted, + failed: summary.failed, + }); + } catch (err) { + evt.setError(err); + recordError(); + throw err; + } + }); +} + +/** + * The medication low-stock pass. The per-medication stamp makes a re-fire + * idempotent; a per-medication failure rides out as the `failed` count. + */ +export async function handleMedicationLowStock(): Promise { + return withBackgroundEvent("job.medication_low_stock", async (evt) => { + try { + const summary = await runMedicationLowStockTick( + getWorkerPrisma(), + new Date(), + ); + evt.setBackground({ + task_name: "job.medication_low_stock", + result: { + users_scanned: summary.usersScanned, + skipped_threshold_off: summary.skippedThresholdOff, + medications_evaluated: summary.medicationsEvaluated, + notified: summary.notified, + rearmed: summary.rearmed, + skipped_already_notified: summary.skippedAlreadyNotified, + skipped_above_threshold: summary.skippedAboveThreshold, + skipped_no_runway: summary.skippedNoRunway, + skipped_no_channel: summary.skippedNoChannel, + failed: summary.failed, + }, + }); + return jobDone({ + users_scanned: summary.usersScanned, + medications_evaluated: summary.medicationsEvaluated, + notified: summary.notified, + rearmed: summary.rearmed, + failed: summary.failed, + }); + } catch (err) { + evt.setError(err); + recordError(); + throw err; + } + }); +} + +/** + * The daily-briefing fallback slot. Most ticks land outside every user's + * slot; that is the tick doing its job, so a zero `sent` is a success. + */ +export async function handleDailyBriefing(): Promise { + return withBackgroundEvent("job.daily_briefing", async (evt) => { + try { + const summary = await runDailyBriefingTick(getWorkerPrisma(), new Date()); + evt.setBackground({ + task_name: "job.daily_briefing", + result: { + candidates_scanned: summary.candidatesScanned, + in_slot: summary.inSlot, + sent: summary.sent, + suppressed_frequency: summary.suppressedFrequency, + no_digest: summary.noDigest, + opted_out: summary.optedOut, + no_channel: summary.noChannel, + outside_window: summary.outsideWindow, + failed: summary.failed, + }, + }); + return jobDone({ + candidates_scanned: summary.candidatesScanned, + in_slot: summary.inSlot, + sent: summary.sent, + failed: summary.failed, + }); + } catch (err) { + evt.setError(err); + recordError(); + throw err; + } + }); +} + +/** + * The period-narrative warm: a scheduled tick (no `userId`) runs the + * boundary-gated nightly fan-out, a `userId` payload warms one account for + * the read-only GET. Two modes on one queue, so the outcome counts them + * apart: a non-boundary night generates nothing and is still a success. + */ +export async function handlePeriodNarrative( + jobs: Job[], +): Promise { + let singleUser = 0; + let scheduled = 0; + let generated = 0; + let failed = 0; + let stoppedEarly = false; + for (const job of jobs) { + try { + if (job.data?.userId) { + await warmOneNarrative(job.data); + singleUser++; + } else { + const summary = await runPeriodNarrativeWarm(getWorkerPrisma(), { + shouldStop: jobBudget(jobs), + }); + scheduled++; + generated += summary.generated; + failed += summary.failed; + stoppedEarly ||= summary.stoppedEarly; + workerLog( + "info", + `[period-narrative] periods=${summary.periods.join(",") || "none"} total=${summary.total} generated=${summary.generated} cached=${summary.cached} skipped=${summary.skipped} insufficient=${summary.insufficient} failed=${summary.failed} budget=${summary.budgetBlocked}`, + ); + } + } catch (err) { + recordError(); + await reportWorkerError(PERIOD_NARRATIVE_QUEUE, err, { + mode: job.data?.userId ? "single-user" : "scheduled", + }); + throw err; + } + } + return jobDone({ + single_user: singleUser, + scheduled, + generated, + failed, + stopped_early: stoppedEarly, + }); +} + +/** + * The Coach memory refresh for one long conversation. An incomplete payload + * is skipped rather than failed, and counted, so a queue full of them does + * not read as a clean run. + */ +export async function handleCoachMemoryRefresh( + jobs: Job[], +): Promise { + let refreshed = 0; + let skippedIncomplete = 0; + for (const job of jobs) { + if (!job.data?.conversationId || !job.data?.userId) { + skippedIncomplete++; + continue; + } + try { + await runCoachMemoryRefresh(job.data); + refreshed++; + } catch (err) { + recordError(); + workerLog("error", "[coach-memory-refresh] failed", err); + throw err; + } + } + return jobDone({ + refreshed, + skipped_incomplete: skippedIncomplete, + }); +} + +/** + * The daily Coach-reminder sweep. Surfacing an overdue reminder and minting + * plan-review reminders are both idempotent across re-fires; no provider call, + * no push. + */ +export async function handleCoachReminderSweep(): Promise { + return withBackgroundEvent("job.coach_reminder_sweep", async (evt) => { + try { + const summary = await runCoachReminderSweep( + getWorkerPrisma(), + new Date(), + ); + evt.setBackground({ + task_name: "job.coach_reminder_sweep", + result: { + reminders_due: summary.remindersDue, + plan_reviews_minted: summary.planReviewsMinted, + context_surfaced: summary.contextSurfaced, + nag_dismissed: summary.nagDismissed, + errored: summary.errored, + }, + }); + return jobDone({ + reminders_due: summary.remindersDue, + plan_reviews_minted: summary.planReviewsMinted, + context_surfaced: summary.contextSurfaced, + nag_dismissed: summary.nagDismissed, + errored: summary.errored, + }); + } catch (err) { + evt.setError(err); + recordError(); + throw err; + } + }); +} + +/** + * The n-of-1 experiment review: writes the grounded before/after read-back + * into the plan's encrypted outcome. A reviewed plan is not re-processed. + */ +export async function handleCoachPlanReview(): Promise { + return withBackgroundEvent("job.coach_plan_review", async (evt) => { + try { + const summary = await runCoachPlanReviewTick( + getWorkerPrisma(), + new Date(), + ); + evt.setBackground({ + task_name: "job.coach_plan_review", + result: { + reviewed: summary.reviewed, + insufficient: summary.insufficient, + errored: summary.errored, + }, + }); + return jobDone({ + reviewed: summary.reviewed, + insufficient: summary.insufficient, + errored: summary.errored, + }); + } catch (err) { + evt.setError(err); + recordError(); + throw err; + } + }); +} diff --git a/src/lib/jobs/restore-drill-queue.ts b/src/lib/jobs/restore-drill-queue.ts new file mode 100644 index 000000000..397def8b5 --- /dev/null +++ b/src/lib/jobs/restore-drill-queue.ts @@ -0,0 +1,23 @@ +/** + * The queue facts of `restore-drill.ts`: its name and, where it has them, its + * schedule, concurrency and send options. They live apart from the job so the + * worker registry and every sender can read them without loading it. + */ +export const RESTORE_DRILL_QUEUE = "data-restore-drill"; + +export const RESTORE_DRILL_CRON = "11 4 1 * *"; + +/** + * Four hours, the off-host upload's own window: the drill downloads and + * stream-parses every object that upload wrote in one night. + */ +export const RESTORE_DRILL_EXPIRE_SECONDS = 4 * 60 * 60; + +/** + * What the monthly schedule sends with: the expiry above and no retry + * (`retryLimit: 0`, the maintenance schedules' `cronIsTheRetry`). + */ +export const RESTORE_DRILL_SEND_OPTIONS = { + expireInSeconds: RESTORE_DRILL_EXPIRE_SECONDS, + retryLimit: 0, +} as const; diff --git a/src/lib/jobs/restore-drill.ts b/src/lib/jobs/restore-drill.ts index 1a6854bc1..a2d38c476 100644 --- a/src/lib/jobs/restore-drill.ts +++ b/src/lib/jobs/restore-drill.ts @@ -48,24 +48,7 @@ import { reportWorkerError } from "@/lib/jobs/report-worker-error"; import { jobBudget } from "@/lib/jobs/job-budget"; import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome"; import { withBackgroundEvent } from "@/lib/logging/background"; - -export const RESTORE_DRILL_QUEUE = "data-restore-drill"; -export const RESTORE_DRILL_CRON = "11 4 1 * *"; - -/** - * Four hours, the off-host upload's own window: the drill downloads and - * stream-parses every object that upload wrote in one night. - */ -export const RESTORE_DRILL_EXPIRE_SECONDS = 4 * 60 * 60; - -/** - * What the monthly schedule sends with: the expiry above and no retry - * (`retryLimit: 0`, the maintenance schedules' `cronIsTheRetry`). - */ -export const RESTORE_DRILL_SEND_OPTIONS = { - expireInSeconds: RESTORE_DRILL_EXPIRE_SECONDS, - retryLimit: 0, -} as const; +import { RESTORE_DRILL_QUEUE } from "./restore-drill-queue"; /** * A drill run that finds the newest backup older than this is reported diff --git a/src/lib/jobs/sleep-timeline-backfill-queue.ts b/src/lib/jobs/sleep-timeline-backfill-queue.ts new file mode 100644 index 000000000..c120b9920 --- /dev/null +++ b/src/lib/jobs/sleep-timeline-backfill-queue.ts @@ -0,0 +1,13 @@ +/** + * The queue facts of `sleep-timeline-backfill.ts`: its name and, where it has them, its + * schedule and concurrency. They live apart from the handler so the worker + * registry and every sender can read them without loading the job. + */ +export const SLEEP_TIMELINE_BACKFILL_QUEUE = "sleep-timeline-backfill"; + +/** + * Serial concurrency — a pass deletes a source's sleep rows then walks a + * re-sync that is rate-bounded by the upstream API cap. Concurrency-1 keeps it + * from crowding the request pool, matching the other backfill queues. + */ +export const SLEEP_TIMELINE_BACKFILL_CONCURRENCY = 1; diff --git a/src/lib/jobs/sleep-timeline-backfill.ts b/src/lib/jobs/sleep-timeline-backfill.ts index 619a632da..2bec8fae6 100644 --- a/src/lib/jobs/sleep-timeline-backfill.ts +++ b/src/lib/jobs/sleep-timeline-backfill.ts @@ -25,7 +25,7 @@ * A reboot mid-pass re-runs delete + re-sync from scratch — the re-sync upserts * are key-stable, so the result converges. * - * Modelled on `whoop-backfill.ts` / `fitbit-backfill.ts`. The queue name MUST + * Modelled on `whoop-backfill.ts` / `fitbit/jobs/backfill.ts`. The queue name MUST * be registered in `allQueues` in `src/lib/jobs/reminder-worker.ts` or pg-boss * never provisions it and the boot enqueue silently never drains (the v1.4.37 * dead-queue class). @@ -40,15 +40,7 @@ import { syncUserSleep as syncWithingsSleep, } from "@/lib/withings/sync-sleep"; import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups"; - -export const SLEEP_TIMELINE_BACKFILL_QUEUE = "sleep-timeline-backfill"; - -/** - * Serial concurrency — a pass deletes a source's sleep rows then walks a - * re-sync that is rate-bounded by the upstream API cap. Concurrency-1 keeps it - * from crowding the request pool, matching the other backfill queues. - */ -export const SLEEP_TIMELINE_BACKFILL_CONCURRENCY = 1; +import { SLEEP_TIMELINE_BACKFILL_QUEUE } from "./sleep-timeline-backfill-queue"; export type SleepTimelineProvider = "WHOOP" | "WITHINGS"; diff --git a/src/lib/jobs/strain-score-queue.ts b/src/lib/jobs/strain-score-queue.ts new file mode 100644 index 000000000..0417a725e --- /dev/null +++ b/src/lib/jobs/strain-score-queue.ts @@ -0,0 +1,13 @@ +/** + * The queue facts of `strain-score.ts`: its name and, where it has them, its + * schedule and concurrency. They live apart from the handler so the worker + * registry and every sender can read them without loading the job. + */ +export const STRAIN_SCORE_QUEUE = "strain-score-compute"; + +/** + * Daily at 04:55 Europe/Berlin — inside the existing maintenance window, + * after the recovery-score (04:45) + stress-score (04:50) passes so the + * nightly score writes stay ordered and don't pile on one boss poll. + */ +export const STRAIN_SCORE_CRON = "55 4 * * *"; diff --git a/src/lib/jobs/strain-score.ts b/src/lib/jobs/strain-score.ts index 2d019fa05..afe09ddab 100644 --- a/src/lib/jobs/strain-score.ts +++ b/src/lib/jobs/strain-score.ts @@ -21,15 +21,6 @@ import type { PrismaClient } from "@/generated/prisma/client"; import { persistStrainScore } from "@/lib/insights/strain-score"; import { runScoreBatch } from "@/lib/insights/score-row"; -export const STRAIN_SCORE_QUEUE = "strain-score-compute"; - -/** - * Daily at 04:55 Europe/Berlin — inside the existing maintenance window, - * after the recovery-score (04:45) + stress-score (04:50) passes so the - * nightly score writes stay ordered and don't pile on one boss poll. - */ -export const STRAIN_SCORE_CRON = "55 4 * * *"; - /** Per-run user cap — same starvation guard the other score passes use. */ export const STRAIN_SCORE_BATCH_CAP = 500; diff --git a/src/lib/jobs/strava-backfill-queue.ts b/src/lib/jobs/strava-backfill-queue.ts new file mode 100644 index 000000000..db638639f --- /dev/null +++ b/src/lib/jobs/strava-backfill-queue.ts @@ -0,0 +1,13 @@ +/** + * The queue facts of `strava-backfill.ts`: its name and, where it has them, its + * schedule and concurrency. They live apart from the handler so the worker + * registry and every sender can read them without loading the job. + */ +export const STRAVA_BACKFILL_QUEUE = "strava-backfill"; + +/** + * Serial concurrency — a backfill walks the account's activity history and is + * rate-bounded by Strava's 200-req / 15-min cap; concurrency-1 keeps it from + * crowding the request pool, matching `WHOOP_BACKFILL_CONCURRENCY`. + */ +export const STRAVA_BACKFILL_CONCURRENCY = 1; diff --git a/src/lib/jobs/strava-backfill.ts b/src/lib/jobs/strava-backfill.ts index 10bb6dd21..a18a85d73 100644 --- a/src/lib/jobs/strava-backfill.ts +++ b/src/lib/jobs/strava-backfill.ts @@ -27,15 +27,7 @@ import { withPageWalkLedger, } from "@/lib/integrations/page-walk"; import { syncStravaHistory } from "@/lib/strava/sync"; - -export const STRAVA_BACKFILL_QUEUE = "strava-backfill"; - -/** - * Serial concurrency — a backfill walks the account's activity history and is - * rate-bounded by Strava's 200-req / 15-min cap; concurrency-1 keeps it from - * crowding the request pool, matching `WHOOP_BACKFILL_CONCURRENCY`. - */ -export const STRAVA_BACKFILL_CONCURRENCY = 1; +import { STRAVA_BACKFILL_QUEUE } from "./strava-backfill-queue"; export interface StravaBackfillPayload { userId: string; diff --git a/src/lib/jobs/stress-score-queue.ts b/src/lib/jobs/stress-score-queue.ts new file mode 100644 index 000000000..70261c31a --- /dev/null +++ b/src/lib/jobs/stress-score-queue.ts @@ -0,0 +1,13 @@ +/** + * The queue facts of `stress-score.ts`: its name and, where it has them, its + * schedule and concurrency. They live apart from the handler so the worker + * registry and every sender can read them without loading the job. + */ +export const STRESS_SCORE_QUEUE = "stress-score-compute"; + +/** + * Daily at 04:50 Europe/Berlin — inside the existing maintenance window, + * after the dense intra-day retention drain (03:50) and the recovery-score + * pass (04:45) so the HRV inputs it reads are settled. + */ +export const STRESS_SCORE_CRON = "50 4 * * *"; diff --git a/src/lib/jobs/stress-score.ts b/src/lib/jobs/stress-score.ts index 04b25cb1d..8a73866bf 100644 --- a/src/lib/jobs/stress-score.ts +++ b/src/lib/jobs/stress-score.ts @@ -22,15 +22,6 @@ import type { PrismaClient } from "@/generated/prisma/client"; import { persistStressScore } from "@/lib/insights/stress-score"; import { runScoreBatch } from "@/lib/insights/score-row"; -export const STRESS_SCORE_QUEUE = "stress-score-compute"; - -/** - * Daily at 04:50 Europe/Berlin — inside the existing maintenance window, - * after the dense intra-day retention drain (03:50) and the recovery-score - * pass (04:45) so the HRV inputs it reads are settled. - */ -export const STRESS_SCORE_CRON = "50 4 * * *"; - /** Per-run user cap — same starvation guard the recovery-score pass uses. */ export const STRESS_SCORE_BATCH_CAP = 500; diff --git a/src/lib/jobs/tls-pin-monitor-queue.ts b/src/lib/jobs/tls-pin-monitor-queue.ts new file mode 100644 index 000000000..f08599e58 --- /dev/null +++ b/src/lib/jobs/tls-pin-monitor-queue.ts @@ -0,0 +1,15 @@ +/** + * The queue facts of `tls-pin-monitor.ts`: its name and, where it has them, its + * schedule and concurrency. They live apart from the handler so the worker + * registry and every sender can read them without loading the job. + */ +/** + * pg-boss queue name + cron. Every 6 hours at :07 (offset off the :00 / :05 + * / :08 hourly sync crons so the probe doesn't pile onto a busy boss poll). + * GTS leaves renew roughly every ~90 days; a 6-hour cadence surfaces a + * change well inside the ≥11-day re-pin window the runbook targets while + * costing one short outbound TLS handshake per tick. + */ +export const TLS_PIN_MONITOR_QUEUE = "tls-pin-monitor"; + +export const TLS_PIN_MONITOR_CRON = "7 */6 * * *"; diff --git a/src/lib/jobs/tls-pin-monitor.ts b/src/lib/jobs/tls-pin-monitor.ts index 3c1c5e394..16cc93c10 100644 --- a/src/lib/jobs/tls-pin-monitor.ts +++ b/src/lib/jobs/tls-pin-monitor.ts @@ -40,16 +40,6 @@ import { type LeafProbeResult, } from "@/lib/tls/leaf-spki"; -/** - * pg-boss queue name + cron. Every 6 hours at :07 (offset off the :00 / :05 - * / :08 hourly sync crons so the probe doesn't pile onto a busy boss poll). - * GTS leaves renew roughly every ~90 days; a 6-hour cadence surfaces a - * change well inside the ≥11-day re-pin window the runbook targets while - * costing one short outbound TLS handshake per tick. - */ -export const TLS_PIN_MONITOR_QUEUE = "tls-pin-monitor"; -export const TLS_PIN_MONITOR_CRON = "7 */6 * * *"; - export interface TlsPinMonitorSummary { /** "ok" served pin is in the known set; "changed" it is not; "skipped" no target / no baseline; "probe_failed" the TLS probe threw. */ outcome: "ok" | "changed" | "skipped" | "probe_failed"; diff --git a/src/lib/jobs/whoop-backfill-queue.ts b/src/lib/jobs/whoop-backfill-queue.ts new file mode 100644 index 000000000..21ea1793e --- /dev/null +++ b/src/lib/jobs/whoop-backfill-queue.ts @@ -0,0 +1,13 @@ +/** + * The queue facts of `whoop-backfill.ts`: its name and, where it has them, its + * schedule and concurrency. They live apart from the handler so the worker + * registry and every sender can read them without loading the job. + */ +export const WHOOP_BACKFILL_QUEUE = "whoop-backfill"; + +/** + * Serial concurrency — a backfill walks years of history for one account and + * is rate-bounded by WHOOP's 100 req/min cap; concurrency-1 keeps it from + * crowding the request pool, matching `ROLLUP_FULL_BACKFILL_CONCURRENCY`. + */ +export const WHOOP_BACKFILL_CONCURRENCY = 1; diff --git a/src/lib/jobs/whoop-backfill.ts b/src/lib/jobs/whoop-backfill.ts index 83d87ad82..d61822d8a 100644 --- a/src/lib/jobs/whoop-backfill.ts +++ b/src/lib/jobs/whoop-backfill.ts @@ -26,15 +26,7 @@ import { withPageWalkLedger, } from "@/lib/integrations/page-walk"; import { syncUserWhoop } from "@/lib/whoop/sync"; - -export const WHOOP_BACKFILL_QUEUE = "whoop-backfill"; - -/** - * Serial concurrency — a backfill walks years of history for one account and - * is rate-bounded by WHOOP's 100 req/min cap; concurrency-1 keeps it from - * crowding the request pool, matching `ROLLUP_FULL_BACKFILL_CONCURRENCY`. - */ -export const WHOOP_BACKFILL_CONCURRENCY = 1; +import { WHOOP_BACKFILL_QUEUE } from "./whoop-backfill-queue"; export interface WhoopBackfillPayload { userId: string; diff --git a/src/lib/medications/intake-import-admission.ts b/src/lib/medications/intake-import-admission.ts index 591140da9..21a487b49 100644 --- a/src/lib/medications/intake-import-admission.ts +++ b/src/lib/medications/intake-import-admission.ts @@ -23,12 +23,12 @@ */ import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { - MEDICATION_INTAKE_IMPORT_QUEUE, MEDICATION_INTAKE_IMPORT_SEND_OPTIONS, MEDICATION_INTAKE_IMPORT_STALE_AFTER_MS, type MedicationImportPayload, type MedicationImportProgress, } from "@/lib/jobs/medication-intake-import"; +import { MEDICATION_INTAKE_IMPORT_QUEUE } from "@/lib/jobs/medication-intake-import-queue"; import { auditLog } from "@/lib/auth/audit"; import { prisma, toJson } from "@/lib/db"; diff --git a/src/lib/nightscout/__tests__/__fixtures__/entries.json b/src/lib/nightscout/__tests__/__fixtures__/entries.json new file mode 100644 index 000000000..4fce586f5 --- /dev/null +++ b/src/lib/nightscout/__tests__/__fixtures__/entries.json @@ -0,0 +1,81 @@ +[ + { + "_id": "6650a1b2c3d4e5f600000001", + "type": "sgv", + "sgv": 112, + "date": 1772445600000, + "dateString": "2026-03-02T10:00:00.000Z", + "direction": "Flat", + "noise": 1, + "device": "example-cgm" + }, + { + "_id": "6650a1b2c3d4e5f600000002", + "type": "sgv", + "sgv": 187.5, + "date": 1772445900000, + "dateString": "2026-03-02T10:05:00.000Z", + "direction": "SingleUp" + }, + { + "sgv": 95, + "date": 1772446200000, + "dateString": "2026-03-02T10:10:00.000Z" + }, + { + "_id": "6650a1b2c3d4e5f600000004", + "type": "mbg", + "mbg": 120, + "date": 1772446500000 + }, + { + "_id": "6650a1b2c3d4e5f600000005", + "type": "cal", + "slope": 850, + "intercept": 32000, + "date": 1772446800000 + }, + { + "_id": "6650a1b2c3d4e5f600000006", + "type": "sgv", + "sgv": null, + "date": 1772447100000 + }, + { + "_id": "6650a1b2c3d4e5f600000007", + "type": "sgv", + "sgv": 140 + }, + { + "_id": "6650a1b2c3d4e5f600000008", + "type": "sgv", + "sgv": 140, + "date": "1772447700000" + }, + { + "_id": "6650a1b2c3d4e5f600000009", + "type": "sgv", + "sgv": "130", + "date": 1772448000000 + }, + { + "_id": 42, + "type": "sgv", + "sgv": 78, + "date": 1772448300000 + }, + null, + "not-an-entry", + { + "_id": "6650a1b2c3d4e5f600000013", + "type": "sgv", + "sgv": 0, + "date": 0 + }, + { + "_id": "", + "type": "", + "sgv": 401, + "date": 1772448600000 + } +] diff --git a/src/lib/nightscout/__tests__/__golden__/map-entries-url.json b/src/lib/nightscout/__tests__/__golden__/map-entries-url.json new file mode 100644 index 000000000..de79fae7f --- /dev/null +++ b/src/lib/nightscout/__tests__/__golden__/map-entries-url.json @@ -0,0 +1,32 @@ +[ + { + "baseUrl": "https://ns.example.org", + "count": 288, + "token": null, + "url": "https://ns.example.org/api/v1/entries.json?count=288&type=sgv" + }, + { + "baseUrl": "https://ns.example.org/", + "count": 288, + "token": "reader-0123456789abcdef", + "url": "https://ns.example.org/api/v1/entries.json?count=288&type=sgv&token=reader-0123456789abcdef" + }, + { + "baseUrl": "https://ns.example.org///", + "count": 1, + "token": "", + "url": "https://ns.example.org/api/v1/entries.json?count=1&type=sgv" + }, + { + "baseUrl": "https://example.org/nightscout", + "count": 50, + "token": "token with spaces&more=1", + "url": "https://example.org/nightscout/api/v1/entries.json?count=50&type=sgv&token=token+with+spaces%26more%3D1" + }, + { + "baseUrl": "http://ns.example.org:1337", + "count": 0, + "token": "reader-token", + "url": "http://ns.example.org:1337/api/v1/entries.json?count=0&type=sgv&token=reader-token" + } +] diff --git a/src/lib/nightscout/__tests__/__golden__/map-entries.json b/src/lib/nightscout/__tests__/__golden__/map-entries.json new file mode 100644 index 000000000..e133e86bc --- /dev/null +++ b/src/lib/nightscout/__tests__/__golden__/map-entries.json @@ -0,0 +1,72 @@ +{ + "parsed": [ + { "id": "6650a1b2c3d4e5f600000001", "sgv": 112, "date": 1772445600000 }, + { "id": "6650a1b2c3d4e5f600000002", "sgv": 187.5, "date": 1772445900000 }, + { "id": null, "sgv": 95, "date": 1772446200000 }, + { "id": null, "sgv": 78, "date": 1772448300000 }, + { "id": "6650a1b2c3d4e5f600000013", "sgv": 0, "date": 0 }, + { "id": "", "sgv": 401, "date": 1772448600000 } + ], + "mapped": [ + { + "externalId": "ns:6650a1b2c3d4e5f600000001", + "measurement": { + "type": "BLOOD_GLUCOSE", + "value": 112, + "unit": "mg/dL", + "measuredAt": "2026-03-02T10:00:00.000Z", + "externalId": "ns:6650a1b2c3d4e5f600000001" + } + }, + { + "externalId": "ns:6650a1b2c3d4e5f600000002", + "measurement": { + "type": "BLOOD_GLUCOSE", + "value": 187.5, + "unit": "mg/dL", + "measuredAt": "2026-03-02T10:05:00.000Z", + "externalId": "ns:6650a1b2c3d4e5f600000002" + } + }, + { + "externalId": "ns:date:1772446200000", + "measurement": { + "type": "BLOOD_GLUCOSE", + "value": 95, + "unit": "mg/dL", + "measuredAt": "2026-03-02T10:10:00.000Z", + "externalId": "ns:date:1772446200000" + } + }, + { + "externalId": "ns:date:1772448300000", + "measurement": { + "type": "BLOOD_GLUCOSE", + "value": 78, + "unit": "mg/dL", + "measuredAt": "2026-03-02T10:45:00.000Z", + "externalId": "ns:date:1772448300000" + } + }, + { + "externalId": "ns:6650a1b2c3d4e5f600000013", + "measurement": { + "type": "BLOOD_GLUCOSE", + "value": 0, + "unit": "mg/dL", + "measuredAt": "1970-01-01T00:00:00.000Z", + "externalId": "ns:6650a1b2c3d4e5f600000013" + } + }, + { + "externalId": "ns:date:1772448600000", + "measurement": { + "type": "BLOOD_GLUCOSE", + "value": 401, + "unit": "mg/dL", + "measuredAt": "2026-03-02T10:50:00.000Z", + "externalId": "ns:date:1772448600000" + } + } + ] +} diff --git a/src/lib/nightscout/__tests__/__golden__/map-non-array-payloads.json b/src/lib/nightscout/__tests__/__golden__/map-non-array-payloads.json new file mode 100644 index 000000000..d43da4394 --- /dev/null +++ b/src/lib/nightscout/__tests__/__golden__/map-non-array-payloads.json @@ -0,0 +1,7 @@ +[ + { "name": "error object", "parsed": [] }, + { "name": "login page", "parsed": [] }, + { "name": "null", "parsed": [] }, + { "name": "number", "parsed": [] }, + { "name": "empty array", "parsed": [] } +] diff --git a/src/lib/nightscout/__tests__/__golden__/map-sha1.json b/src/lib/nightscout/__tests__/__golden__/map-sha1.json new file mode 100644 index 000000000..62df38f8f --- /dev/null +++ b/src/lib/nightscout/__tests__/__golden__/map-sha1.json @@ -0,0 +1,11 @@ +[ + { "value": "", "sha1": "da39a3ee5e6b4b0d3255bfef95601890afd80709" }, + { + "value": "example-api-secret", + "sha1": "ae2d76b7397b1eed862868b41007d75ab043c31c" + }, + { + "value": "secret-with-umlaut-äöü", + "sha1": "7f13c4ff16a35003f36132c3ba41217605e390f0" + } +] diff --git a/src/lib/nightscout/__tests__/map-fixtures.test.ts b/src/lib/nightscout/__tests__/map-fixtures.test.ts new file mode 100644 index 000000000..96fb182e7 --- /dev/null +++ b/src/lib/nightscout/__tests__/map-fixtures.test.ts @@ -0,0 +1,91 @@ +/** + * What the Nightscout parse and mapping make of a fixed set of payloads, + * pinned in `__golden__/map-*.json`. + * + * `__fixtures__/entries.json` is a synthetic `/api/v1/entries.json` answer in + * the documented wire shape, mixing what an instance really returns: typed + * and untyped SGV rows, meter-BG and calibration rows, a missing value or + * timestamp, string-typed numbers, a non-string `_id`, non-object rows and + * zero values. The URL builder, the SHA1 helper and the non-array payloads + * run from small tables in this file. + * + * The golden was written before the mappers moved out of `client.ts`, so a + * green run is the proof that the move changed nothing. + */ +import { describe, expect, it } from "vitest"; +import { format } from "prettier"; + +import { + buildEntriesUrl, + externalIdFor, + mapSgvEntryToMeasurement, + parseSgvEntries, + sha1Hex, +} from "../client"; + +import entriesFixture from "./__fixtures__/entries.json"; + +/** The golden is written in the repository's JSON format, so the format check passes on it unchanged. */ +async function golden(value: unknown): Promise { + return format(JSON.stringify(value), { parser: "json" }); +} + +describe("Nightscout mappers against the golden fixtures", () => { + it("parses SGV entries and maps each to a measurement", async () => { + const parsed = parseSgvEntries(entriesFixture); + const output = { + parsed, + mapped: parsed.map((entry) => ({ + externalId: externalIdFor(entry), + measurement: mapSgvEntryToMeasurement(entry), + })), + }; + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-entries.json", + ); + }); + + it("answers an empty list for payloads that are not an entry array", async () => { + const payloads: Array<[string, unknown]> = [ + ["error object", { status: 401, message: "Unauthorized" }], + ["login page", "Nightscout"], + ["null", null], + ["number", 7], + ["empty array", []], + ]; + const output = payloads.map(([name, payload]) => ({ + name, + parsed: parseSgvEntries(payload), + })); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-non-array-payloads.json", + ); + }); + + it("builds the entries URL", async () => { + const inputs: Array<[string, number, string | undefined]> = [ + ["https://ns.example.org", 288, undefined], + ["https://ns.example.org/", 288, "reader-0123456789abcdef"], + ["https://ns.example.org///", 1, ""], + ["https://example.org/nightscout", 50, "token with spaces&more=1"], + ["http://ns.example.org:1337", 0, "reader-token"], + ]; + const output = inputs.map(([baseUrl, count, token]) => ({ + baseUrl, + count, + token: token ?? null, + url: buildEntriesUrl(baseUrl, count, token), + })); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-entries-url.json", + ); + }); + + it("hashes the API secret", async () => { + const inputs = ["", "example-api-secret", "secret-with-umlaut-äöü"]; + const output = inputs.map((value) => ({ value, sha1: sha1Hex(value) })); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-sha1.json", + ); + }); +}); diff --git a/src/lib/nightscout/__tests__/sync-plausibility.test.ts b/src/lib/nightscout/__tests__/sync-plausibility.test.ts index 24c023eb7..eb8b8942a 100644 --- a/src/lib/nightscout/__tests__/sync-plausibility.test.ts +++ b/src/lib/nightscout/__tests__/sync-plausibility.test.ts @@ -32,9 +32,10 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({ collapseToTypeDayKeys: (rows: Array<{ type: string; measuredAt: Date }>) => rows.map((r) => ({ type: r.type, measuredAt: r.measuredAt })), recomputeBucketsForMeasurement: recomputeMock, + recomputeBucketsForKeys: recomputeMock, })); -vi.mock("@/lib/insights/comprehensive-generate", () => ({ +vi.mock("@/lib/insights/status-invalidation", () => ({ invalidateStatusInsightsForTypes: invalidateMock, })); diff --git a/src/lib/nightscout/__tests__/sync.test.ts b/src/lib/nightscout/__tests__/sync.test.ts index 18f3c8a9b..d5aa762e3 100644 --- a/src/lib/nightscout/__tests__/sync.test.ts +++ b/src/lib/nightscout/__tests__/sync.test.ts @@ -51,7 +51,7 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({ }, })); -vi.mock("@/lib/insights/comprehensive-generate", () => ({ +vi.mock("@/lib/insights/status-invalidation", () => ({ invalidateStatusInsightsForTypes: invalidateMock, })); diff --git a/src/lib/nightscout/sync.ts b/src/lib/nightscout/sync.ts index fa3998a0b..f6d0728fd 100644 --- a/src/lib/nightscout/sync.ts +++ b/src/lib/nightscout/sync.ts @@ -32,16 +32,9 @@ import { } from "@/lib/integrations/status"; import type { IntegrationClassification } from "@/lib/integrations/http-status-classifier"; import type { SyncWriteResult } from "@/lib/outcome/written-outcome"; -import { - collapseToTypeDayKeys, - recomputeBucketsForKeys, -} from "@/lib/rollups/measurement-rollups"; -import { - emitInsertedMeasurementArrivals, - type InsertedMeasurementArrivalRow, -} from "@/lib/arrivals/measurement-emit"; +import { afterProviderWrite } from "@/lib/integrations/sync-kit/after-write"; +import type { InsertedMeasurementArrivalRow } from "@/lib/arrivals/measurement-emit"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; -import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; import { fetchSgvEntries, mapSgvEntryToMeasurement, @@ -319,29 +312,12 @@ export async function upsertNightscoutEntries( // inert for glucose today — it removes Nightscout as the spine's odd one out // and lights up automatically once a `glucose` arrival kind + its consuming // surface land in a later release. - void emitInsertedMeasurementArrivals( + await afterProviderWrite({ userId, - insertedRows, - "nightscout", - ).catch(() => {}); - - try { - const keys = collapseToTypeDayKeys(touched); - await recomputeBucketsForKeys(userId, keys); - invalidateStatusInsightsForTypes( - userId, - keys.map((k) => k.type), - { source: "sync" }, - ).catch((err) => { - getEvent()?.addWarning( - `nightscout: status-insight invalidate failed for ${userId}: ${err}`, - ); - }); - } catch (err) { - getEvent()?.addWarning( - `nightscout: rollup fold failed for ${userId}: ${err}`, - ); - } + provider: "nightscout", + touched, + arrivals: { rows: insertedRows, source: "nightscout" }, + }); return { inserted: insertedRows.length, failedRows }; } diff --git a/src/lib/oura/__tests__/__fixtures__/cardiovascular-age.json b/src/lib/oura/__tests__/__fixtures__/cardiovascular-age.json new file mode 100644 index 000000000..97ee2c448 --- /dev/null +++ b/src/lib/oura/__tests__/__fixtures__/cardiovascular-age.json @@ -0,0 +1,7 @@ +[ + { "id": "cv-age-0001", "day": "2026-03-02", "vascular_age": 38 }, + { "id": "cv-age-0002", "day": "2026-03-03", "vascular_age": null }, + { "id": "cv-age-0003", "day": "2026-03-04", "vascular_age": 0 }, + { "day": "2026-03-05", "vascular_age": 41.555 }, + { "id": "cv-age-0005", "day": "2026-03-06" } +] diff --git a/src/lib/oura/__tests__/__fixtures__/cycle-phases.json b/src/lib/oura/__tests__/__fixtures__/cycle-phases.json new file mode 100644 index 000000000..e77ed018a --- /dev/null +++ b/src/lib/oura/__tests__/__fixtures__/cycle-phases.json @@ -0,0 +1,65 @@ +[ + { + "scenario": "luteal then follicular on adjacent days marks the follicular day", + "records": [ + { "id": "cycle-0001", "day": "2026-03-01", "phase": "luteal" }, + { "id": "cycle-0002", "day": "2026-03-02", "phase": "luteal" }, + { "id": "cycle-0003", "day": "2026-03-03", "phase": "follicular" }, + { "id": "cycle-0004", "day": "2026-03-04", "phase": "follicular" } + ] + }, + { + "scenario": "a gap between luteal and follicular marks nothing", + "records": [ + { "id": "cycle-0011", "day": "2026-03-10", "phase": "luteal" }, + { "id": "cycle-0012", "day": "2026-03-12", "phase": "follicular" } + ] + }, + { + "scenario": "a literal menstrual phase marks its own day", + "records": [ + { "id": "cycle-0021", "day": "2026-04-01", "phase": "menstrual" }, + { "id": "cycle-0022", "day": "2026-04-02", "phase": "Menstrual" }, + { "id": "cycle-0023", "day": "2026-04-03", "phase": "follicular" } + ] + }, + { + "scenario": "page order is not chronological", + "records": [ + { "id": "cycle-0033", "day": "2026-05-03", "phase": "follicular" }, + { "id": "cycle-0031", "day": "2026-05-01", "phase": "luteal" }, + { "id": "cycle-0032", "day": "2026-05-02", "phase": "LUTEAL" } + ] + }, + { + "scenario": "a null phase does not break the run, an unknown phase does", + "records": [ + { "id": "cycle-0041", "day": "2026-06-01", "phase": "luteal" }, + { "id": "cycle-0042", "day": "2026-06-02", "phase": null }, + { "id": "cycle-0043", "day": "2026-06-03", "phase": "follicular" }, + { "id": "cycle-0044", "day": "2026-06-10", "phase": "luteal" }, + { "id": "cycle-0045", "day": "2026-06-11", "phase": "ovulatory" }, + { "id": "cycle-0046", "day": "2026-06-12", "phase": "follicular" } + ] + }, + { + "scenario": "records without a day are dropped and duplicates collapse", + "records": [ + { "id": "cycle-0051", "day": "", "phase": "menstrual" }, + { "id": "cycle-0052", "day": "2026-07-01", "phase": "menstrual" }, + { "id": "cycle-0053", "day": "2026-07-01", "phase": "menstrual" }, + { "day": "2026-07-02" } + ] + }, + { + "scenario": "month boundary counts as adjacent", + "records": [ + { "id": "cycle-0061", "day": "2026-08-31", "phase": "luteal" }, + { "id": "cycle-0062", "day": "2026-09-01", "phase": "follicular" } + ] + }, + { + "scenario": "an empty series", + "records": [] + } +] diff --git a/src/lib/oura/__tests__/__fixtures__/daily-activity.json b/src/lib/oura/__tests__/__fixtures__/daily-activity.json new file mode 100644 index 000000000..98f99eaa4 --- /dev/null +++ b/src/lib/oura/__tests__/__fixtures__/daily-activity.json @@ -0,0 +1,48 @@ +[ + { + "id": "activity-0001", + "day": "2026-03-02", + "timestamp": "2026-03-02T04:00:00-05:00", + "steps": 8432, + "active_calories": 412.7, + "total_calories": 2380, + "equivalent_walking_distance": 9100 + }, + { + "id": "activity-0002", + "day": "2026-03-03", + "timestamp": "2026-03-03T04:00:00-05:00", + "steps": 0, + "active_calories": 0 + }, + { + "id": "activity-0003", + "day": "2026-03-04", + "steps": null, + "active_calories": 250 + }, + { + "id": "activity-0004", + "day": "2026-03-05", + "steps": -5, + "active_calories": -1 + }, + { + "id": "activity-0005", + "day": "2026-03-06", + "steps": 1234.6, + "active_calories": 98.765 + }, + { + "id": "activity-0006", + "day": "2026-03-07", + "equivalent_walking_distance": 4200 + }, + { + "id": "activity-0007", + "day": "2026-03-08", + "timestamp": "not-a-date", + "steps": 5000, + "active_calories": 300 + } +] diff --git a/src/lib/oura/__tests__/__fixtures__/daily-sleep.json b/src/lib/oura/__tests__/__fixtures__/daily-sleep.json new file mode 100644 index 000000000..31d04c8a8 --- /dev/null +++ b/src/lib/oura/__tests__/__fixtures__/daily-sleep.json @@ -0,0 +1,18 @@ +[ + { + "id": "daily-sleep-0001", + "day": "2026-03-02", + "timestamp": "2026-03-02T00:00:00+00:00", + "score": 84 + }, + { "id": "daily-sleep-0002", "day": "2026-03-03", "score": null }, + { "id": "daily-sleep-0003", "day": "2026-03-04", "score": 0 }, + { + "id": "daily-sleep-0004", + "day": "2026-03-05", + "timestamp": "not-a-date", + "score": 77 + }, + { "id": "daily-sleep-0005", "day": "2026-03-06", "score": 90.555 }, + { "id": "daily-sleep-0006", "day": "2026-03-07" } +] diff --git a/src/lib/oura/__tests__/__fixtures__/daily-spo2.json b/src/lib/oura/__tests__/__fixtures__/daily-spo2.json new file mode 100644 index 000000000..fc6602119 --- /dev/null +++ b/src/lib/oura/__tests__/__fixtures__/daily-spo2.json @@ -0,0 +1,29 @@ +[ + { + "id": "spo2-0001", + "day": "2026-03-02", + "spo2_percentage": { "average": 96.4 } + }, + { + "id": "spo2-0002", + "day": "2026-03-03", + "spo2_percentage": { "average": null } + }, + { "id": "spo2-0003", "day": "2026-03-04", "spo2_percentage": null }, + { "id": "spo2-0004", "day": "2026-03-05" }, + { + "id": "spo2-0005", + "day": "2026-03-06", + "spo2_percentage": { "average": 0 } + }, + { + "id": "spo2-0006", + "day": "2026-03-07", + "spo2_percentage": { "average": 97.125 } + }, + { + "id": "spo2-0007", + "day": "2026-03-08", + "spo2_percentage": {} + } +] diff --git a/src/lib/oura/__tests__/__fixtures__/heartrate.json b/src/lib/oura/__tests__/__fixtures__/heartrate.json new file mode 100644 index 000000000..6fcb38a5c --- /dev/null +++ b/src/lib/oura/__tests__/__fixtures__/heartrate.json @@ -0,0 +1,17 @@ +[ + { "bpm": 62, "source": "awake", "timestamp": "2026-03-02T08:00:00Z" }, + { "bpm": 19, "source": "rest", "timestamp": "2026-03-02T08:05:00Z" }, + { "bpm": 20, "source": "sleep", "timestamp": "2026-03-02T03:00:00Z" }, + { "bpm": 250, "source": "workout", "timestamp": "2026-03-02T17:30:00Z" }, + { "bpm": 251, "source": "workout", "timestamp": "2026-03-02T17:31:00Z" }, + { "bpm": 71.6, "source": "live", "timestamp": "2026-03-02T09:00:00.000Z" }, + { "bpm": null, "source": "awake", "timestamp": "2026-03-02T09:05:00Z" }, + { "bpm": 66, "source": "awake" }, + { "bpm": 66, "source": "awake", "timestamp": "not-a-date" }, + { + "bpm": 74, + "source": "session", + "timestamp": "2026-03-02T10:15:30+02:00" + }, + { "bpm": 55, "source": null, "timestamp": null } +] diff --git a/src/lib/oura/__tests__/__fixtures__/readiness.json b/src/lib/oura/__tests__/__fixtures__/readiness.json new file mode 100644 index 000000000..376b5d6d0 --- /dev/null +++ b/src/lib/oura/__tests__/__fixtures__/readiness.json @@ -0,0 +1,71 @@ +[ + { + "id": "readiness-0001", + "day": "2026-03-02", + "timestamp": "2026-03-02T00:00:00+00:00", + "score": 82, + "temperature_deviation": -0.21, + "temperature_trend_deviation": 0.05, + "contributors": { + "activity_balance": 78, + "body_temperature": 95, + "hrv_balance": 70, + "previous_day_activity": 84, + "previous_night": 80, + "recovery_index": 90, + "resting_heart_rate": 88, + "sleep_balance": 76 + } + }, + { + "id": "readiness-0002", + "day": "2026-03-03", + "score": 75, + "temperature_deviation": 0.35 + }, + { + "id": "readiness-0003", + "day": "2026-03-04", + "score": null, + "temperature_deviation": 0.1, + "contributors": null + }, + { + "id": "readiness-0004", + "day": "2026-03-05", + "timestamp": "2026-03-05T00:00:00+00:00", + "score": 0, + "temperature_deviation": 0 + }, + { + "id": "readiness-0005", + "day": "2026-03-06", + "timestamp": "2026-03-06T00:00:00+00:00", + "score": null, + "temperature_deviation": null + }, + { + "id": "readiness-0006", + "day": "2026-03-07", + "timestamp": "not-a-date", + "score": 70, + "temperature_deviation": 0.2 + }, + { + "id": "readiness-0007", + "day": "2026-03-08", + "timestamp": "2026-03-08T06:15:00+02:00", + "score": 88.456, + "temperature_deviation": -0.125 + }, + { + "id": "readiness-0008", + "day": "2026-03-29", + "score": 64 + }, + { + "id": "readiness-0009", + "day": "2026-11-01", + "temperature_deviation": 0.48 + } +] diff --git a/src/lib/oura/__tests__/__fixtures__/resilience.json b/src/lib/oura/__tests__/__fixtures__/resilience.json new file mode 100644 index 000000000..bd47d1ef0 --- /dev/null +++ b/src/lib/oura/__tests__/__fixtures__/resilience.json @@ -0,0 +1,11 @@ +[ + { "id": "resilience-0001", "day": "2026-03-02", "level": "limited" }, + { "id": "resilience-0002", "day": "2026-03-03", "level": "adequate" }, + { "id": "resilience-0003", "day": "2026-03-04", "level": "Solid" }, + { "id": "resilience-0004", "day": "2026-03-05", "level": "STRONG" }, + { "id": "resilience-0005", "day": "2026-03-06", "level": "exceptional" }, + { "id": "resilience-0006", "day": "2026-03-07", "level": "unknown_band" }, + { "id": "resilience-0007", "day": "2026-03-08", "level": null }, + { "id": "resilience-0008", "day": "2026-03-09", "level": "" }, + { "day": "2026-03-10", "level": "solid" } +] diff --git a/src/lib/oura/__tests__/__fixtures__/sleep.json b/src/lib/oura/__tests__/__fixtures__/sleep.json new file mode 100644 index 000000000..91af9ec17 --- /dev/null +++ b/src/lib/oura/__tests__/__fixtures__/sleep.json @@ -0,0 +1,135 @@ +[ + { + "id": "sleep-0001", + "day": "2026-03-02", + "type": "long_sleep", + "bedtime_start": "2026-03-01T22:30:00+01:00", + "bedtime_end": "2026-03-02T06:40:00+01:00", + "total_sleep_duration": 26400, + "time_in_bed": 29400, + "efficiency": 91, + "light_sleep_duration": 14400, + "deep_sleep_duration": 5400, + "rem_sleep_duration": 6600, + "awake_time": 3000, + "average_heart_rate": 58.25, + "lowest_heart_rate": 52.4, + "average_hrv": 48.5, + "average_breath": 14.25, + "sleep_phase_5_min": "44222211113333222241" + }, + { + "id": "sleep-0002", + "day": "2026-03-03", + "type": "sleep", + "bedtime_start": "2026-03-02T23:00:00+01:00", + "bedtime_end": "2026-03-03T07:00:00+01:00", + "efficiency": 88, + "light_sleep_duration": 14400, + "deep_sleep_duration": 5400, + "rem_sleep_duration": 6300, + "awake_time": 1800, + "lowest_heart_rate": 0, + "average_hrv": 0, + "average_breath": null, + "sleep_phase_5_min": null + }, + { + "id": "sleep-0003", + "day": "2026-03-02", + "type": "late_nap", + "bedtime_start": "2026-03-02T14:10:00+01:00", + "bedtime_end": "2026-03-02T14:40:00+01:00", + "efficiency": 83.333, + "light_sleep_duration": 1500, + "deep_sleep_duration": 0, + "rem_sleep_duration": null, + "awake_time": 0, + "lowest_heart_rate": 61.6, + "average_hrv": 35.125, + "average_breath": 15 + }, + { + "id": "sleep-0004", + "day": "2026-03-04", + "type": "rest", + "bedtime_start": "2026-03-04T15:00:00+01:00", + "bedtime_end": "2026-03-04T15:45:00+01:00", + "efficiency": 70, + "light_sleep_duration": 2100, + "average_hrv": 40 + }, + { + "id": "sleep-0005", + "day": "2026-03-05", + "type": "deleted", + "bedtime_start": "2026-03-04T23:30:00+01:00", + "bedtime_end": "2026-03-05T06:30:00+01:00", + "efficiency": 90, + "sleep_phase_5_min": "2222" + }, + { + "id": "sleep-0006", + "day": "2026-03-06", + "light_sleep_duration": 12000, + "deep_sleep_duration": 4800, + "rem_sleep_duration": 5700, + "awake_time": 900, + "average_breath": 13.8, + "sleep_phase_5_min": "22113322" + }, + { + "id": "sleep-0007", + "day": "2026-03-07", + "type": "long_sleep", + "bedtime_start": "2026-03-06T23:15:00-05:00", + "bedtime_end": "2026-03-07T00:00:00-05:00", + "efficiency": 85, + "sleep_phase_5_min": "0011522" + }, + { + "id": "sleep-0008", + "day": "2026-03-08", + "type": "long_sleep", + "bedtime_start": "2026-03-07T23:00:00+00:00", + "bedtime_end": "2026-03-08T07:00:00+00:00", + "light_sleep_duration": 13200, + "deep_sleep_duration": 6000, + "rem_sleep_duration": 6000, + "awake_time": 1200, + "sleep_phase_5_min": "0000" + }, + { + "id": "sleep-0009", + "day": "2026-03-09", + "type": "long_sleep", + "bedtime_start": "2026-03-08T23:00:00+00:00", + "bedtime_end": "garbage", + "efficiency": 90, + "sleep_phase_5_min": "2222" + }, + { + "id": "sleep-0010", + "day": "2026-03-10", + "type": "sleep", + "bedtime_start": "not-a-date", + "bedtime_end": "2026-03-10T06:00:00+00:00", + "light_sleep_duration": -60, + "deep_sleep_duration": 3601, + "rem_sleep_duration": 4000, + "awake_time": null, + "average_hrv": -3, + "lowest_heart_rate": 49.5, + "sleep_phase_5_min": "1122" + }, + { + "id": "sleep-0011", + "day": "2026-03-11", + "type": "sleep", + "bedtime_start": "2026-03-10T22:00:00+00:00", + "bedtime_end": "2026-03-11T06:00:00+00:00", + "efficiency": 0, + "light_sleep_duration": 0, + "sleep_phase_5_min": "" + } +] diff --git a/src/lib/oura/__tests__/__fixtures__/vo2-max.json b/src/lib/oura/__tests__/__fixtures__/vo2-max.json new file mode 100644 index 000000000..05411e3ac --- /dev/null +++ b/src/lib/oura/__tests__/__fixtures__/vo2-max.json @@ -0,0 +1,18 @@ +[ + { + "id": "vo2-0001", + "day": "2026-03-02", + "timestamp": "2026-03-02T00:00:00+00:00", + "vo2_max": 42.3 + }, + { "id": "vo2-0002", "day": "2026-03-03", "vo2_max": null }, + { "id": "vo2-0003", "day": "2026-03-04", "vo2_max": 0 }, + { "id": "vo2-0004", "day": "2026-03-05", "vo2_max": -1 }, + { "id": "vo2-0005", "day": "2026-03-06", "vo2_max": 38.756 }, + { + "id": "vo2-0006", + "day": "2026-03-07", + "timestamp": "not-a-date", + "vo2_max": 40 + } +] diff --git a/src/lib/oura/__tests__/__fixtures__/workouts.json b/src/lib/oura/__tests__/__fixtures__/workouts.json new file mode 100644 index 000000000..0ee80fc55 --- /dev/null +++ b/src/lib/oura/__tests__/__fixtures__/workouts.json @@ -0,0 +1,77 @@ +[ + { + "id": "workout-0001", + "day": "2026-03-02", + "activity": "running", + "calories": 412, + "distance": 7520.5, + "start_datetime": "2026-03-02T07:00:00+01:00", + "end_datetime": "2026-03-02T07:45:30+01:00", + "intensity": "moderate", + "source": "confirmed", + "label": "Morning loop" + }, + { + "id": "workout-0002", + "day": "2026-03-03", + "activity": "underwater_basket_weaving", + "calories": 120.6, + "distance": null, + "start_datetime": "2026-03-03T18:00:00-05:00", + "end_datetime": "2026-03-03T18:30:00-05:00", + "intensity": "easy", + "source": "manual" + }, + { + "id": "workout-0003", + "activity": null, + "calories": null, + "distance": 0, + "start_datetime": "2026-03-04T12:00:00Z", + "end_datetime": "2026-03-04T12:20:00Z" + }, + { + "id": "workout-0004", + "activity": "cycling", + "start_datetime": "2026-03-05T10:00:00Z", + "end_datetime": "2026-03-05T09:59:59Z" + }, + { + "id": "", + "activity": "walking", + "start_datetime": "2026-03-06T10:00:00Z", + "end_datetime": "2026-03-06T10:30:00Z" + }, + { + "id": "workout-0006", + "activity": "walking", + "start_datetime": "not-a-date", + "end_datetime": "2026-03-07T10:30:00Z" + }, + { + "id": "workout-0007", + "activity": "Yoga", + "calories": -5, + "distance": -10, + "start_datetime": "2026-03-08T06:30:00Z", + "end_datetime": "2026-03-08T06:30:00Z", + "intensity": "hard", + "source": "autodetected" + }, + { + "id": "workout-0008", + "activity": "strength_training", + "calories": 0, + "start_datetime": "2026-03-09T17:00:00.250Z", + "end_datetime": "2026-03-09T17:50:00.749Z", + "source": "workout_heart_rate" + }, + { + "id": "workout-0009", + "activity": "hiking", + "calories": 980.4, + "distance": 15200, + "start_datetime": "2026-03-10T09:00:00+01:00", + "end_datetime": "not-a-date" + } +] diff --git a/src/lib/oura/__tests__/__golden__/map-cardiovascular-age.json b/src/lib/oura/__tests__/__golden__/map-cardiovascular-age.json new file mode 100644 index 000000000..778dc4d3b --- /dev/null +++ b/src/lib/oura/__tests__/__golden__/map-cardiovascular-age.json @@ -0,0 +1,125 @@ +[ + { + "id": "cv-age-0001", + "day": "2026-03-02", + "zone": null, + "rows": [ + { + "type": "VASCULAR_AGE", + "value": 38, + "unit": "years", + "measuredAt": "2026-03-02T12:00:00.000Z", + "fieldTag": "vascular_age" + } + ] + }, + { + "id": "cv-age-0001", + "day": "2026-03-02", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "VASCULAR_AGE", + "value": 38, + "unit": "years", + "measuredAt": "2026-03-02T11:00:00.000Z", + "fieldTag": "vascular_age" + } + ] + }, + { + "id": "cv-age-0001", + "day": "2026-03-02", + "zone": "America/New_York", + "rows": [ + { + "type": "VASCULAR_AGE", + "value": 38, + "unit": "years", + "measuredAt": "2026-03-02T17:00:00.000Z", + "fieldTag": "vascular_age" + } + ] + }, + { "id": "cv-age-0002", "day": "2026-03-03", "zone": null, "rows": [] }, + { + "id": "cv-age-0002", + "day": "2026-03-03", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "cv-age-0002", + "day": "2026-03-03", + "zone": "America/New_York", + "rows": [] + }, + { "id": "cv-age-0003", "day": "2026-03-04", "zone": null, "rows": [] }, + { + "id": "cv-age-0003", + "day": "2026-03-04", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "cv-age-0003", + "day": "2026-03-04", + "zone": "America/New_York", + "rows": [] + }, + { + "id": null, + "day": "2026-03-05", + "zone": null, + "rows": [ + { + "type": "VASCULAR_AGE", + "value": 41.56, + "unit": "years", + "measuredAt": "2026-03-05T12:00:00.000Z", + "fieldTag": "vascular_age" + } + ] + }, + { + "id": null, + "day": "2026-03-05", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "VASCULAR_AGE", + "value": 41.56, + "unit": "years", + "measuredAt": "2026-03-05T11:00:00.000Z", + "fieldTag": "vascular_age" + } + ] + }, + { + "id": null, + "day": "2026-03-05", + "zone": "America/New_York", + "rows": [ + { + "type": "VASCULAR_AGE", + "value": 41.56, + "unit": "years", + "measuredAt": "2026-03-05T17:00:00.000Z", + "fieldTag": "vascular_age" + } + ] + }, + { "id": "cv-age-0005", "day": "2026-03-06", "zone": null, "rows": [] }, + { + "id": "cv-age-0005", + "day": "2026-03-06", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "cv-age-0005", + "day": "2026-03-06", + "zone": "America/New_York", + "rows": [] + } +] diff --git a/src/lib/oura/__tests__/__golden__/map-cycle-phases.json b/src/lib/oura/__tests__/__golden__/map-cycle-phases.json new file mode 100644 index 000000000..22caa166b --- /dev/null +++ b/src/lib/oura/__tests__/__golden__/map-cycle-phases.json @@ -0,0 +1,31 @@ +[ + { + "scenario": "luteal then follicular on adjacent days marks the follicular day", + "periodDays": ["2026-03-03"] + }, + { + "scenario": "a gap between luteal and follicular marks nothing", + "periodDays": [] + }, + { + "scenario": "a literal menstrual phase marks its own day", + "periodDays": ["2026-04-01", "2026-04-02"] + }, + { + "scenario": "page order is not chronological", + "periodDays": ["2026-05-03"] + }, + { + "scenario": "a null phase does not break the run, an unknown phase does", + "periodDays": [] + }, + { + "scenario": "records without a day are dropped and duplicates collapse", + "periodDays": ["2026-07-01"] + }, + { + "scenario": "month boundary counts as adjacent", + "periodDays": ["2026-09-01"] + }, + { "scenario": "an empty series", "periodDays": [] } +] diff --git a/src/lib/oura/__tests__/__golden__/map-daily-activity.json b/src/lib/oura/__tests__/__golden__/map-daily-activity.json new file mode 100644 index 000000000..daeaaaba7 --- /dev/null +++ b/src/lib/oura/__tests__/__golden__/map-daily-activity.json @@ -0,0 +1,272 @@ +[ + { + "id": "activity-0001", + "day": "2026-03-02", + "zone": null, + "rows": [ + { + "type": "ACTIVITY_STEPS", + "value": 8432, + "unit": "steps", + "measuredAt": "2026-03-02T09:00:00.000Z", + "fieldTag": "steps" + }, + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 412.7, + "unit": "kcal", + "measuredAt": "2026-03-02T09:00:00.000Z", + "fieldTag": "active_energy" + } + ] + }, + { + "id": "activity-0001", + "day": "2026-03-02", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "ACTIVITY_STEPS", + "value": 8432, + "unit": "steps", + "measuredAt": "2026-03-02T09:00:00.000Z", + "fieldTag": "steps" + }, + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 412.7, + "unit": "kcal", + "measuredAt": "2026-03-02T09:00:00.000Z", + "fieldTag": "active_energy" + } + ] + }, + { + "id": "activity-0001", + "day": "2026-03-02", + "zone": "America/New_York", + "rows": [ + { + "type": "ACTIVITY_STEPS", + "value": 8432, + "unit": "steps", + "measuredAt": "2026-03-02T09:00:00.000Z", + "fieldTag": "steps" + }, + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 412.7, + "unit": "kcal", + "measuredAt": "2026-03-02T09:00:00.000Z", + "fieldTag": "active_energy" + } + ] + }, + { + "id": "activity-0002", + "day": "2026-03-03", + "zone": null, + "rows": [ + { + "type": "ACTIVITY_STEPS", + "value": 0, + "unit": "steps", + "measuredAt": "2026-03-03T09:00:00.000Z", + "fieldTag": "steps" + }, + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 0, + "unit": "kcal", + "measuredAt": "2026-03-03T09:00:00.000Z", + "fieldTag": "active_energy" + } + ] + }, + { + "id": "activity-0002", + "day": "2026-03-03", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "ACTIVITY_STEPS", + "value": 0, + "unit": "steps", + "measuredAt": "2026-03-03T09:00:00.000Z", + "fieldTag": "steps" + }, + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 0, + "unit": "kcal", + "measuredAt": "2026-03-03T09:00:00.000Z", + "fieldTag": "active_energy" + } + ] + }, + { + "id": "activity-0002", + "day": "2026-03-03", + "zone": "America/New_York", + "rows": [ + { + "type": "ACTIVITY_STEPS", + "value": 0, + "unit": "steps", + "measuredAt": "2026-03-03T09:00:00.000Z", + "fieldTag": "steps" + }, + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 0, + "unit": "kcal", + "measuredAt": "2026-03-03T09:00:00.000Z", + "fieldTag": "active_energy" + } + ] + }, + { + "id": "activity-0003", + "day": "2026-03-04", + "zone": null, + "rows": [ + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 250, + "unit": "kcal", + "measuredAt": "2026-03-04T12:00:00.000Z", + "fieldTag": "active_energy" + } + ] + }, + { + "id": "activity-0003", + "day": "2026-03-04", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 250, + "unit": "kcal", + "measuredAt": "2026-03-04T11:00:00.000Z", + "fieldTag": "active_energy" + } + ] + }, + { + "id": "activity-0003", + "day": "2026-03-04", + "zone": "America/New_York", + "rows": [ + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 250, + "unit": "kcal", + "measuredAt": "2026-03-04T17:00:00.000Z", + "fieldTag": "active_energy" + } + ] + }, + { "id": "activity-0004", "day": "2026-03-05", "zone": null, "rows": [] }, + { + "id": "activity-0004", + "day": "2026-03-05", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "activity-0004", + "day": "2026-03-05", + "zone": "America/New_York", + "rows": [] + }, + { + "id": "activity-0005", + "day": "2026-03-06", + "zone": null, + "rows": [ + { + "type": "ACTIVITY_STEPS", + "value": 1235, + "unit": "steps", + "measuredAt": "2026-03-06T12:00:00.000Z", + "fieldTag": "steps" + }, + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 98.77, + "unit": "kcal", + "measuredAt": "2026-03-06T12:00:00.000Z", + "fieldTag": "active_energy" + } + ] + }, + { + "id": "activity-0005", + "day": "2026-03-06", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "ACTIVITY_STEPS", + "value": 1235, + "unit": "steps", + "measuredAt": "2026-03-06T11:00:00.000Z", + "fieldTag": "steps" + }, + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 98.77, + "unit": "kcal", + "measuredAt": "2026-03-06T11:00:00.000Z", + "fieldTag": "active_energy" + } + ] + }, + { + "id": "activity-0005", + "day": "2026-03-06", + "zone": "America/New_York", + "rows": [ + { + "type": "ACTIVITY_STEPS", + "value": 1235, + "unit": "steps", + "measuredAt": "2026-03-06T17:00:00.000Z", + "fieldTag": "steps" + }, + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 98.77, + "unit": "kcal", + "measuredAt": "2026-03-06T17:00:00.000Z", + "fieldTag": "active_energy" + } + ] + }, + { "id": "activity-0006", "day": "2026-03-07", "zone": null, "rows": [] }, + { + "id": "activity-0006", + "day": "2026-03-07", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "activity-0006", + "day": "2026-03-07", + "zone": "America/New_York", + "rows": [] + }, + { "id": "activity-0007", "day": "2026-03-08", "zone": null, "rows": [] }, + { + "id": "activity-0007", + "day": "2026-03-08", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "activity-0007", + "day": "2026-03-08", + "zone": "America/New_York", + "rows": [] + } +] diff --git a/src/lib/oura/__tests__/__golden__/map-daily-sleep.json b/src/lib/oura/__tests__/__golden__/map-daily-sleep.json new file mode 100644 index 000000000..926e6ce9a --- /dev/null +++ b/src/lib/oura/__tests__/__golden__/map-daily-sleep.json @@ -0,0 +1,167 @@ +[ + { + "id": "daily-sleep-0001", + "day": "2026-03-02", + "zone": null, + "rows": [ + { + "type": "SLEEP_SCORE", + "value": 84, + "unit": "score", + "measuredAt": "2026-03-02T00:00:00.000Z", + "fieldTag": "sleep_score" + } + ] + }, + { + "id": "daily-sleep-0001", + "day": "2026-03-02", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "SLEEP_SCORE", + "value": 84, + "unit": "score", + "measuredAt": "2026-03-02T00:00:00.000Z", + "fieldTag": "sleep_score" + } + ] + }, + { + "id": "daily-sleep-0001", + "day": "2026-03-02", + "zone": "America/New_York", + "rows": [ + { + "type": "SLEEP_SCORE", + "value": 84, + "unit": "score", + "measuredAt": "2026-03-02T00:00:00.000Z", + "fieldTag": "sleep_score" + } + ] + }, + { "id": "daily-sleep-0002", "day": "2026-03-03", "zone": null, "rows": [] }, + { + "id": "daily-sleep-0002", + "day": "2026-03-03", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "daily-sleep-0002", + "day": "2026-03-03", + "zone": "America/New_York", + "rows": [] + }, + { + "id": "daily-sleep-0003", + "day": "2026-03-04", + "zone": null, + "rows": [ + { + "type": "SLEEP_SCORE", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-04T12:00:00.000Z", + "fieldTag": "sleep_score" + } + ] + }, + { + "id": "daily-sleep-0003", + "day": "2026-03-04", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "SLEEP_SCORE", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-04T11:00:00.000Z", + "fieldTag": "sleep_score" + } + ] + }, + { + "id": "daily-sleep-0003", + "day": "2026-03-04", + "zone": "America/New_York", + "rows": [ + { + "type": "SLEEP_SCORE", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-04T17:00:00.000Z", + "fieldTag": "sleep_score" + } + ] + }, + { "id": "daily-sleep-0004", "day": "2026-03-05", "zone": null, "rows": [] }, + { + "id": "daily-sleep-0004", + "day": "2026-03-05", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "daily-sleep-0004", + "day": "2026-03-05", + "zone": "America/New_York", + "rows": [] + }, + { + "id": "daily-sleep-0005", + "day": "2026-03-06", + "zone": null, + "rows": [ + { + "type": "SLEEP_SCORE", + "value": 90.56, + "unit": "score", + "measuredAt": "2026-03-06T12:00:00.000Z", + "fieldTag": "sleep_score" + } + ] + }, + { + "id": "daily-sleep-0005", + "day": "2026-03-06", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "SLEEP_SCORE", + "value": 90.56, + "unit": "score", + "measuredAt": "2026-03-06T11:00:00.000Z", + "fieldTag": "sleep_score" + } + ] + }, + { + "id": "daily-sleep-0005", + "day": "2026-03-06", + "zone": "America/New_York", + "rows": [ + { + "type": "SLEEP_SCORE", + "value": 90.56, + "unit": "score", + "measuredAt": "2026-03-06T17:00:00.000Z", + "fieldTag": "sleep_score" + } + ] + }, + { "id": "daily-sleep-0006", "day": "2026-03-07", "zone": null, "rows": [] }, + { + "id": "daily-sleep-0006", + "day": "2026-03-07", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "daily-sleep-0006", + "day": "2026-03-07", + "zone": "America/New_York", + "rows": [] + } +] diff --git a/src/lib/oura/__tests__/__golden__/map-daily-spo2.json b/src/lib/oura/__tests__/__golden__/map-daily-spo2.json new file mode 100644 index 000000000..6c6ff7f74 --- /dev/null +++ b/src/lib/oura/__tests__/__golden__/map-daily-spo2.json @@ -0,0 +1,151 @@ +[ + { + "id": "spo2-0001", + "day": "2026-03-02", + "zone": null, + "rows": [ + { + "type": "OXYGEN_SATURATION", + "value": 96.4, + "unit": "%", + "measuredAt": "2026-03-02T12:00:00.000Z", + "fieldTag": "spo2" + } + ] + }, + { + "id": "spo2-0001", + "day": "2026-03-02", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "OXYGEN_SATURATION", + "value": 96.4, + "unit": "%", + "measuredAt": "2026-03-02T11:00:00.000Z", + "fieldTag": "spo2" + } + ] + }, + { + "id": "spo2-0001", + "day": "2026-03-02", + "zone": "America/New_York", + "rows": [ + { + "type": "OXYGEN_SATURATION", + "value": 96.4, + "unit": "%", + "measuredAt": "2026-03-02T17:00:00.000Z", + "fieldTag": "spo2" + } + ] + }, + { "id": "spo2-0002", "day": "2026-03-03", "zone": null, "rows": [] }, + { + "id": "spo2-0002", + "day": "2026-03-03", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "spo2-0002", + "day": "2026-03-03", + "zone": "America/New_York", + "rows": [] + }, + { "id": "spo2-0003", "day": "2026-03-04", "zone": null, "rows": [] }, + { + "id": "spo2-0003", + "day": "2026-03-04", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "spo2-0003", + "day": "2026-03-04", + "zone": "America/New_York", + "rows": [] + }, + { "id": "spo2-0004", "day": "2026-03-05", "zone": null, "rows": [] }, + { + "id": "spo2-0004", + "day": "2026-03-05", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "spo2-0004", + "day": "2026-03-05", + "zone": "America/New_York", + "rows": [] + }, + { "id": "spo2-0005", "day": "2026-03-06", "zone": null, "rows": [] }, + { + "id": "spo2-0005", + "day": "2026-03-06", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "spo2-0005", + "day": "2026-03-06", + "zone": "America/New_York", + "rows": [] + }, + { + "id": "spo2-0006", + "day": "2026-03-07", + "zone": null, + "rows": [ + { + "type": "OXYGEN_SATURATION", + "value": 97.13, + "unit": "%", + "measuredAt": "2026-03-07T12:00:00.000Z", + "fieldTag": "spo2" + } + ] + }, + { + "id": "spo2-0006", + "day": "2026-03-07", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "OXYGEN_SATURATION", + "value": 97.13, + "unit": "%", + "measuredAt": "2026-03-07T11:00:00.000Z", + "fieldTag": "spo2" + } + ] + }, + { + "id": "spo2-0006", + "day": "2026-03-07", + "zone": "America/New_York", + "rows": [ + { + "type": "OXYGEN_SATURATION", + "value": 97.13, + "unit": "%", + "measuredAt": "2026-03-07T17:00:00.000Z", + "fieldTag": "spo2" + } + ] + }, + { "id": "spo2-0007", "day": "2026-03-08", "zone": null, "rows": [] }, + { + "id": "spo2-0007", + "day": "2026-03-08", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "spo2-0007", + "day": "2026-03-08", + "zone": "America/New_York", + "rows": [] + } +] diff --git a/src/lib/oura/__tests__/__golden__/map-heartrate.json b/src/lib/oura/__tests__/__golden__/map-heartrate.json new file mode 100644 index 000000000..af2f27680 --- /dev/null +++ b/src/lib/oura/__tests__/__golden__/map-heartrate.json @@ -0,0 +1,68 @@ +[ + { + "timestamp": "2026-03-02T08:00:00Z", + "bpm": 62, + "row": { + "type": "PULSE", + "value": 62, + "unit": "bpm", + "measuredAt": "2026-03-02T08:00:00.000Z", + "fieldTag": "hr", + "externalId": "heartrate:2026-03-02T08:00:00.000Z" + } + }, + { "timestamp": "2026-03-02T08:05:00Z", "bpm": 19, "row": null }, + { + "timestamp": "2026-03-02T03:00:00Z", + "bpm": 20, + "row": { + "type": "PULSE", + "value": 20, + "unit": "bpm", + "measuredAt": "2026-03-02T03:00:00.000Z", + "fieldTag": "hr", + "externalId": "heartrate:2026-03-02T03:00:00.000Z" + } + }, + { + "timestamp": "2026-03-02T17:30:00Z", + "bpm": 250, + "row": { + "type": "PULSE", + "value": 250, + "unit": "bpm", + "measuredAt": "2026-03-02T17:30:00.000Z", + "fieldTag": "hr", + "externalId": "heartrate:2026-03-02T17:30:00.000Z" + } + }, + { "timestamp": "2026-03-02T17:31:00Z", "bpm": 251, "row": null }, + { + "timestamp": "2026-03-02T09:00:00.000Z", + "bpm": 71.6, + "row": { + "type": "PULSE", + "value": 72, + "unit": "bpm", + "measuredAt": "2026-03-02T09:00:00.000Z", + "fieldTag": "hr", + "externalId": "heartrate:2026-03-02T09:00:00.000Z" + } + }, + { "timestamp": "2026-03-02T09:05:00Z", "bpm": null, "row": null }, + { "timestamp": null, "bpm": 66, "row": null }, + { "timestamp": "not-a-date", "bpm": 66, "row": null }, + { + "timestamp": "2026-03-02T10:15:30+02:00", + "bpm": 74, + "row": { + "type": "PULSE", + "value": 74, + "unit": "bpm", + "measuredAt": "2026-03-02T08:15:30.000Z", + "fieldTag": "hr", + "externalId": "heartrate:2026-03-02T08:15:30.000Z" + } + }, + { "timestamp": null, "bpm": 55, "row": null } +] diff --git a/src/lib/oura/__tests__/__golden__/map-readiness.json b/src/lib/oura/__tests__/__golden__/map-readiness.json new file mode 100644 index 000000000..ceecdbd39 --- /dev/null +++ b/src/lib/oura/__tests__/__golden__/map-readiness.json @@ -0,0 +1,406 @@ +[ + { + "id": "readiness-0001", + "day": "2026-03-02", + "zone": null, + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 82, + "unit": "score", + "measuredAt": "2026-03-02T00:00:00.000Z", + "fieldTag": "recovery" + }, + { + "type": "BODY_TEMPERATURE_DEVIATION", + "value": -0.21, + "unit": "celsius", + "measuredAt": "2026-03-02T00:00:00.000Z", + "fieldTag": "temp_deviation" + } + ] + }, + { + "id": "readiness-0001", + "day": "2026-03-02", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 82, + "unit": "score", + "measuredAt": "2026-03-02T00:00:00.000Z", + "fieldTag": "recovery" + }, + { + "type": "BODY_TEMPERATURE_DEVIATION", + "value": -0.21, + "unit": "celsius", + "measuredAt": "2026-03-02T00:00:00.000Z", + "fieldTag": "temp_deviation" + } + ] + }, + { + "id": "readiness-0001", + "day": "2026-03-02", + "zone": "America/New_York", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 82, + "unit": "score", + "measuredAt": "2026-03-02T00:00:00.000Z", + "fieldTag": "recovery" + }, + { + "type": "BODY_TEMPERATURE_DEVIATION", + "value": -0.21, + "unit": "celsius", + "measuredAt": "2026-03-02T00:00:00.000Z", + "fieldTag": "temp_deviation" + } + ] + }, + { + "id": "readiness-0002", + "day": "2026-03-03", + "zone": null, + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 75, + "unit": "score", + "measuredAt": "2026-03-03T12:00:00.000Z", + "fieldTag": "recovery" + }, + { + "type": "BODY_TEMPERATURE_DEVIATION", + "value": 0.35, + "unit": "celsius", + "measuredAt": "2026-03-03T12:00:00.000Z", + "fieldTag": "temp_deviation" + } + ] + }, + { + "id": "readiness-0002", + "day": "2026-03-03", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 75, + "unit": "score", + "measuredAt": "2026-03-03T11:00:00.000Z", + "fieldTag": "recovery" + }, + { + "type": "BODY_TEMPERATURE_DEVIATION", + "value": 0.35, + "unit": "celsius", + "measuredAt": "2026-03-03T11:00:00.000Z", + "fieldTag": "temp_deviation" + } + ] + }, + { + "id": "readiness-0002", + "day": "2026-03-03", + "zone": "America/New_York", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 75, + "unit": "score", + "measuredAt": "2026-03-03T17:00:00.000Z", + "fieldTag": "recovery" + }, + { + "type": "BODY_TEMPERATURE_DEVIATION", + "value": 0.35, + "unit": "celsius", + "measuredAt": "2026-03-03T17:00:00.000Z", + "fieldTag": "temp_deviation" + } + ] + }, + { + "id": "readiness-0003", + "day": "2026-03-04", + "zone": null, + "rows": [ + { + "type": "BODY_TEMPERATURE_DEVIATION", + "value": 0.1, + "unit": "celsius", + "measuredAt": "2026-03-04T12:00:00.000Z", + "fieldTag": "temp_deviation" + } + ] + }, + { + "id": "readiness-0003", + "day": "2026-03-04", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "BODY_TEMPERATURE_DEVIATION", + "value": 0.1, + "unit": "celsius", + "measuredAt": "2026-03-04T11:00:00.000Z", + "fieldTag": "temp_deviation" + } + ] + }, + { + "id": "readiness-0003", + "day": "2026-03-04", + "zone": "America/New_York", + "rows": [ + { + "type": "BODY_TEMPERATURE_DEVIATION", + "value": 0.1, + "unit": "celsius", + "measuredAt": "2026-03-04T17:00:00.000Z", + "fieldTag": "temp_deviation" + } + ] + }, + { + "id": "readiness-0004", + "day": "2026-03-05", + "zone": null, + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-05T00:00:00.000Z", + "fieldTag": "recovery" + }, + { + "type": "BODY_TEMPERATURE_DEVIATION", + "value": 0, + "unit": "celsius", + "measuredAt": "2026-03-05T00:00:00.000Z", + "fieldTag": "temp_deviation" + } + ] + }, + { + "id": "readiness-0004", + "day": "2026-03-05", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-05T00:00:00.000Z", + "fieldTag": "recovery" + }, + { + "type": "BODY_TEMPERATURE_DEVIATION", + "value": 0, + "unit": "celsius", + "measuredAt": "2026-03-05T00:00:00.000Z", + "fieldTag": "temp_deviation" + } + ] + }, + { + "id": "readiness-0004", + "day": "2026-03-05", + "zone": "America/New_York", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-05T00:00:00.000Z", + "fieldTag": "recovery" + }, + { + "type": "BODY_TEMPERATURE_DEVIATION", + "value": 0, + "unit": "celsius", + "measuredAt": "2026-03-05T00:00:00.000Z", + "fieldTag": "temp_deviation" + } + ] + }, + { "id": "readiness-0005", "day": "2026-03-06", "zone": null, "rows": [] }, + { + "id": "readiness-0005", + "day": "2026-03-06", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "readiness-0005", + "day": "2026-03-06", + "zone": "America/New_York", + "rows": [] + }, + { "id": "readiness-0006", "day": "2026-03-07", "zone": null, "rows": [] }, + { + "id": "readiness-0006", + "day": "2026-03-07", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "readiness-0006", + "day": "2026-03-07", + "zone": "America/New_York", + "rows": [] + }, + { + "id": "readiness-0007", + "day": "2026-03-08", + "zone": null, + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 88.46, + "unit": "score", + "measuredAt": "2026-03-08T04:15:00.000Z", + "fieldTag": "recovery" + }, + { + "type": "BODY_TEMPERATURE_DEVIATION", + "value": -0.13, + "unit": "celsius", + "measuredAt": "2026-03-08T04:15:00.000Z", + "fieldTag": "temp_deviation" + } + ] + }, + { + "id": "readiness-0007", + "day": "2026-03-08", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 88.46, + "unit": "score", + "measuredAt": "2026-03-08T04:15:00.000Z", + "fieldTag": "recovery" + }, + { + "type": "BODY_TEMPERATURE_DEVIATION", + "value": -0.13, + "unit": "celsius", + "measuredAt": "2026-03-08T04:15:00.000Z", + "fieldTag": "temp_deviation" + } + ] + }, + { + "id": "readiness-0007", + "day": "2026-03-08", + "zone": "America/New_York", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 88.46, + "unit": "score", + "measuredAt": "2026-03-08T04:15:00.000Z", + "fieldTag": "recovery" + }, + { + "type": "BODY_TEMPERATURE_DEVIATION", + "value": -0.13, + "unit": "celsius", + "measuredAt": "2026-03-08T04:15:00.000Z", + "fieldTag": "temp_deviation" + } + ] + }, + { + "id": "readiness-0008", + "day": "2026-03-29", + "zone": null, + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 64, + "unit": "score", + "measuredAt": "2026-03-29T12:00:00.000Z", + "fieldTag": "recovery" + } + ] + }, + { + "id": "readiness-0008", + "day": "2026-03-29", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 64, + "unit": "score", + "measuredAt": "2026-03-29T10:00:00.000Z", + "fieldTag": "recovery" + } + ] + }, + { + "id": "readiness-0008", + "day": "2026-03-29", + "zone": "America/New_York", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 64, + "unit": "score", + "measuredAt": "2026-03-29T16:00:00.000Z", + "fieldTag": "recovery" + } + ] + }, + { + "id": "readiness-0009", + "day": "2026-11-01", + "zone": null, + "rows": [ + { + "type": "BODY_TEMPERATURE_DEVIATION", + "value": 0.48, + "unit": "celsius", + "measuredAt": "2026-11-01T12:00:00.000Z", + "fieldTag": "temp_deviation" + } + ] + }, + { + "id": "readiness-0009", + "day": "2026-11-01", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "BODY_TEMPERATURE_DEVIATION", + "value": 0.48, + "unit": "celsius", + "measuredAt": "2026-11-01T11:00:00.000Z", + "fieldTag": "temp_deviation" + } + ] + }, + { + "id": "readiness-0009", + "day": "2026-11-01", + "zone": "America/New_York", + "rows": [ + { + "type": "BODY_TEMPERATURE_DEVIATION", + "value": 0.48, + "unit": "celsius", + "measuredAt": "2026-11-01T17:00:00.000Z", + "fieldTag": "temp_deviation" + } + ] + } +] diff --git a/src/lib/oura/__tests__/__golden__/map-resilience.json b/src/lib/oura/__tests__/__golden__/map-resilience.json new file mode 100644 index 000000000..dd7879350 --- /dev/null +++ b/src/lib/oura/__tests__/__golden__/map-resilience.json @@ -0,0 +1,293 @@ +[ + { + "id": "resilience-0001", + "day": "2026-03-02", + "zone": null, + "rows": [ + { + "type": "RESILIENCE", + "value": 1, + "unit": "level", + "measuredAt": "2026-03-02T12:00:00.000Z", + "fieldTag": "resilience" + } + ] + }, + { + "id": "resilience-0001", + "day": "2026-03-02", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "RESILIENCE", + "value": 1, + "unit": "level", + "measuredAt": "2026-03-02T11:00:00.000Z", + "fieldTag": "resilience" + } + ] + }, + { + "id": "resilience-0001", + "day": "2026-03-02", + "zone": "America/New_York", + "rows": [ + { + "type": "RESILIENCE", + "value": 1, + "unit": "level", + "measuredAt": "2026-03-02T17:00:00.000Z", + "fieldTag": "resilience" + } + ] + }, + { + "id": "resilience-0002", + "day": "2026-03-03", + "zone": null, + "rows": [ + { + "type": "RESILIENCE", + "value": 2, + "unit": "level", + "measuredAt": "2026-03-03T12:00:00.000Z", + "fieldTag": "resilience" + } + ] + }, + { + "id": "resilience-0002", + "day": "2026-03-03", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "RESILIENCE", + "value": 2, + "unit": "level", + "measuredAt": "2026-03-03T11:00:00.000Z", + "fieldTag": "resilience" + } + ] + }, + { + "id": "resilience-0002", + "day": "2026-03-03", + "zone": "America/New_York", + "rows": [ + { + "type": "RESILIENCE", + "value": 2, + "unit": "level", + "measuredAt": "2026-03-03T17:00:00.000Z", + "fieldTag": "resilience" + } + ] + }, + { + "id": "resilience-0003", + "day": "2026-03-04", + "zone": null, + "rows": [ + { + "type": "RESILIENCE", + "value": 3, + "unit": "level", + "measuredAt": "2026-03-04T12:00:00.000Z", + "fieldTag": "resilience" + } + ] + }, + { + "id": "resilience-0003", + "day": "2026-03-04", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "RESILIENCE", + "value": 3, + "unit": "level", + "measuredAt": "2026-03-04T11:00:00.000Z", + "fieldTag": "resilience" + } + ] + }, + { + "id": "resilience-0003", + "day": "2026-03-04", + "zone": "America/New_York", + "rows": [ + { + "type": "RESILIENCE", + "value": 3, + "unit": "level", + "measuredAt": "2026-03-04T17:00:00.000Z", + "fieldTag": "resilience" + } + ] + }, + { + "id": "resilience-0004", + "day": "2026-03-05", + "zone": null, + "rows": [ + { + "type": "RESILIENCE", + "value": 4, + "unit": "level", + "measuredAt": "2026-03-05T12:00:00.000Z", + "fieldTag": "resilience" + } + ] + }, + { + "id": "resilience-0004", + "day": "2026-03-05", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "RESILIENCE", + "value": 4, + "unit": "level", + "measuredAt": "2026-03-05T11:00:00.000Z", + "fieldTag": "resilience" + } + ] + }, + { + "id": "resilience-0004", + "day": "2026-03-05", + "zone": "America/New_York", + "rows": [ + { + "type": "RESILIENCE", + "value": 4, + "unit": "level", + "measuredAt": "2026-03-05T17:00:00.000Z", + "fieldTag": "resilience" + } + ] + }, + { + "id": "resilience-0005", + "day": "2026-03-06", + "zone": null, + "rows": [ + { + "type": "RESILIENCE", + "value": 5, + "unit": "level", + "measuredAt": "2026-03-06T12:00:00.000Z", + "fieldTag": "resilience" + } + ] + }, + { + "id": "resilience-0005", + "day": "2026-03-06", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "RESILIENCE", + "value": 5, + "unit": "level", + "measuredAt": "2026-03-06T11:00:00.000Z", + "fieldTag": "resilience" + } + ] + }, + { + "id": "resilience-0005", + "day": "2026-03-06", + "zone": "America/New_York", + "rows": [ + { + "type": "RESILIENCE", + "value": 5, + "unit": "level", + "measuredAt": "2026-03-06T17:00:00.000Z", + "fieldTag": "resilience" + } + ] + }, + { "id": "resilience-0006", "day": "2026-03-07", "zone": null, "rows": [] }, + { + "id": "resilience-0006", + "day": "2026-03-07", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "resilience-0006", + "day": "2026-03-07", + "zone": "America/New_York", + "rows": [] + }, + { "id": "resilience-0007", "day": "2026-03-08", "zone": null, "rows": [] }, + { + "id": "resilience-0007", + "day": "2026-03-08", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "resilience-0007", + "day": "2026-03-08", + "zone": "America/New_York", + "rows": [] + }, + { "id": "resilience-0008", "day": "2026-03-09", "zone": null, "rows": [] }, + { + "id": "resilience-0008", + "day": "2026-03-09", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "resilience-0008", + "day": "2026-03-09", + "zone": "America/New_York", + "rows": [] + }, + { + "id": null, + "day": "2026-03-10", + "zone": null, + "rows": [ + { + "type": "RESILIENCE", + "value": 3, + "unit": "level", + "measuredAt": "2026-03-10T12:00:00.000Z", + "fieldTag": "resilience" + } + ] + }, + { + "id": null, + "day": "2026-03-10", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "RESILIENCE", + "value": 3, + "unit": "level", + "measuredAt": "2026-03-10T11:00:00.000Z", + "fieldTag": "resilience" + } + ] + }, + { + "id": null, + "day": "2026-03-10", + "zone": "America/New_York", + "rows": [ + { + "type": "RESILIENCE", + "value": 3, + "unit": "level", + "measuredAt": "2026-03-10T16:00:00.000Z", + "fieldTag": "resilience" + } + ] + } +] diff --git a/src/lib/oura/__tests__/__golden__/map-sleep-retired.json b/src/lib/oura/__tests__/__golden__/map-sleep-retired.json new file mode 100644 index 000000000..49c23ab16 --- /dev/null +++ b/src/lib/oura/__tests__/__golden__/map-sleep-retired.json @@ -0,0 +1,11 @@ +[ + { "type": "long_sleep", "retired": false }, + { "type": "sleep", "retired": false }, + { "type": "late_nap", "retired": false }, + { "type": "rest", "retired": true }, + { "type": "deleted", "retired": true }, + { "type": "REST", "retired": false }, + { "type": "", "retired": false }, + { "type": null, "retired": false }, + { "type": null, "retired": false } +] diff --git a/src/lib/oura/__tests__/__golden__/map-sleep.json b/src/lib/oura/__tests__/__golden__/map-sleep.json new file mode 100644 index 000000000..7d4cf8747 --- /dev/null +++ b/src/lib/oura/__tests__/__golden__/map-sleep.json @@ -0,0 +1,1298 @@ +[ + { + "id": "sleep-0001", + "day": "2026-03-02", + "zone": null, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 10, + "unit": "minutes", + "measuredAt": "2026-03-01T21:40:00.000Z", + "fieldTag": "seg:2026-03-01T21:30:00.000Z", + "sleepStage": "AWAKE", + "externalId": "sleep:sleep-0001:seg:2026-03-01T21:30:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 20, + "unit": "minutes", + "measuredAt": "2026-03-01T22:00:00.000Z", + "fieldTag": "seg:2026-03-01T21:40:00.000Z", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0001:seg:2026-03-01T21:40:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 20, + "unit": "minutes", + "measuredAt": "2026-03-01T22:20:00.000Z", + "fieldTag": "seg:2026-03-01T22:00:00.000Z", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0001:seg:2026-03-01T22:00:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 20, + "unit": "minutes", + "measuredAt": "2026-03-01T22:40:00.000Z", + "fieldTag": "seg:2026-03-01T22:20:00.000Z", + "sleepStage": "REM", + "externalId": "sleep:sleep-0001:seg:2026-03-01T22:20:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 20, + "unit": "minutes", + "measuredAt": "2026-03-01T23:00:00.000Z", + "fieldTag": "seg:2026-03-01T22:40:00.000Z", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0001:seg:2026-03-01T22:40:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 5, + "unit": "minutes", + "measuredAt": "2026-03-01T23:05:00.000Z", + "fieldTag": "seg:2026-03-01T23:00:00.000Z", + "sleepStage": "AWAKE", + "externalId": "sleep:sleep-0001:seg:2026-03-01T23:00:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 5, + "unit": "minutes", + "measuredAt": "2026-03-01T23:10:00.000Z", + "fieldTag": "seg:2026-03-01T23:05:00.000Z", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0001:seg:2026-03-01T23:05:00.000Z" + }, + { + "type": "SLEEP_EFFICIENCY", + "value": 91, + "unit": "%", + "measuredAt": "2026-03-02T05:40:00.000Z", + "fieldTag": "sleep_eff", + "externalId": "sleep:sleep-0001:sleep_eff" + }, + { + "type": "HRV_RMSSD", + "value": 48.5, + "unit": "ms", + "measuredAt": "2026-03-02T05:40:00.000Z", + "fieldTag": "hrv_rmssd", + "externalId": "sleep:sleep-0001:hrv_rmssd" + }, + { + "type": "RESTING_HEART_RATE", + "value": 52, + "unit": "bpm", + "measuredAt": "2026-03-02T05:40:00.000Z", + "fieldTag": "rhr", + "externalId": "sleep:sleep-0001:rhr" + }, + { + "type": "RESPIRATORY_RATE", + "value": 14.25, + "unit": "breaths/min", + "measuredAt": "2026-03-02T05:40:00.000Z", + "fieldTag": "resp_rate", + "externalId": "sleep:sleep-0001:resp_rate" + } + ], + "retired": false + }, + { + "id": "sleep-0001", + "day": "2026-03-02", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 10, + "unit": "minutes", + "measuredAt": "2026-03-01T21:40:00.000Z", + "fieldTag": "seg:2026-03-01T21:30:00.000Z", + "sleepStage": "AWAKE", + "externalId": "sleep:sleep-0001:seg:2026-03-01T21:30:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 20, + "unit": "minutes", + "measuredAt": "2026-03-01T22:00:00.000Z", + "fieldTag": "seg:2026-03-01T21:40:00.000Z", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0001:seg:2026-03-01T21:40:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 20, + "unit": "minutes", + "measuredAt": "2026-03-01T22:20:00.000Z", + "fieldTag": "seg:2026-03-01T22:00:00.000Z", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0001:seg:2026-03-01T22:00:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 20, + "unit": "minutes", + "measuredAt": "2026-03-01T22:40:00.000Z", + "fieldTag": "seg:2026-03-01T22:20:00.000Z", + "sleepStage": "REM", + "externalId": "sleep:sleep-0001:seg:2026-03-01T22:20:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 20, + "unit": "minutes", + "measuredAt": "2026-03-01T23:00:00.000Z", + "fieldTag": "seg:2026-03-01T22:40:00.000Z", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0001:seg:2026-03-01T22:40:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 5, + "unit": "minutes", + "measuredAt": "2026-03-01T23:05:00.000Z", + "fieldTag": "seg:2026-03-01T23:00:00.000Z", + "sleepStage": "AWAKE", + "externalId": "sleep:sleep-0001:seg:2026-03-01T23:00:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 5, + "unit": "minutes", + "measuredAt": "2026-03-01T23:10:00.000Z", + "fieldTag": "seg:2026-03-01T23:05:00.000Z", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0001:seg:2026-03-01T23:05:00.000Z" + }, + { + "type": "SLEEP_EFFICIENCY", + "value": 91, + "unit": "%", + "measuredAt": "2026-03-02T05:40:00.000Z", + "fieldTag": "sleep_eff", + "externalId": "sleep:sleep-0001:sleep_eff" + }, + { + "type": "HRV_RMSSD", + "value": 48.5, + "unit": "ms", + "measuredAt": "2026-03-02T05:40:00.000Z", + "fieldTag": "hrv_rmssd", + "externalId": "sleep:sleep-0001:hrv_rmssd" + }, + { + "type": "RESTING_HEART_RATE", + "value": 52, + "unit": "bpm", + "measuredAt": "2026-03-02T05:40:00.000Z", + "fieldTag": "rhr", + "externalId": "sleep:sleep-0001:rhr" + }, + { + "type": "RESPIRATORY_RATE", + "value": 14.25, + "unit": "breaths/min", + "measuredAt": "2026-03-02T05:40:00.000Z", + "fieldTag": "resp_rate", + "externalId": "sleep:sleep-0001:resp_rate" + } + ], + "retired": false + }, + { + "id": "sleep-0001", + "day": "2026-03-02", + "zone": "America/New_York", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 10, + "unit": "minutes", + "measuredAt": "2026-03-01T21:40:00.000Z", + "fieldTag": "seg:2026-03-01T21:30:00.000Z", + "sleepStage": "AWAKE", + "externalId": "sleep:sleep-0001:seg:2026-03-01T21:30:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 20, + "unit": "minutes", + "measuredAt": "2026-03-01T22:00:00.000Z", + "fieldTag": "seg:2026-03-01T21:40:00.000Z", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0001:seg:2026-03-01T21:40:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 20, + "unit": "minutes", + "measuredAt": "2026-03-01T22:20:00.000Z", + "fieldTag": "seg:2026-03-01T22:00:00.000Z", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0001:seg:2026-03-01T22:00:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 20, + "unit": "minutes", + "measuredAt": "2026-03-01T22:40:00.000Z", + "fieldTag": "seg:2026-03-01T22:20:00.000Z", + "sleepStage": "REM", + "externalId": "sleep:sleep-0001:seg:2026-03-01T22:20:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 20, + "unit": "minutes", + "measuredAt": "2026-03-01T23:00:00.000Z", + "fieldTag": "seg:2026-03-01T22:40:00.000Z", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0001:seg:2026-03-01T22:40:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 5, + "unit": "minutes", + "measuredAt": "2026-03-01T23:05:00.000Z", + "fieldTag": "seg:2026-03-01T23:00:00.000Z", + "sleepStage": "AWAKE", + "externalId": "sleep:sleep-0001:seg:2026-03-01T23:00:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 5, + "unit": "minutes", + "measuredAt": "2026-03-01T23:10:00.000Z", + "fieldTag": "seg:2026-03-01T23:05:00.000Z", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0001:seg:2026-03-01T23:05:00.000Z" + }, + { + "type": "SLEEP_EFFICIENCY", + "value": 91, + "unit": "%", + "measuredAt": "2026-03-02T05:40:00.000Z", + "fieldTag": "sleep_eff", + "externalId": "sleep:sleep-0001:sleep_eff" + }, + { + "type": "HRV_RMSSD", + "value": 48.5, + "unit": "ms", + "measuredAt": "2026-03-02T05:40:00.000Z", + "fieldTag": "hrv_rmssd", + "externalId": "sleep:sleep-0001:hrv_rmssd" + }, + { + "type": "RESTING_HEART_RATE", + "value": 52, + "unit": "bpm", + "measuredAt": "2026-03-02T05:40:00.000Z", + "fieldTag": "rhr", + "externalId": "sleep:sleep-0001:rhr" + }, + { + "type": "RESPIRATORY_RATE", + "value": 14.25, + "unit": "breaths/min", + "measuredAt": "2026-03-02T05:40:00.000Z", + "fieldTag": "resp_rate", + "externalId": "sleep:sleep-0001:resp_rate" + } + ], + "retired": false + }, + { + "id": "sleep-0002", + "day": "2026-03-03", + "zone": null, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 240, + "unit": "minutes", + "measuredAt": "2026-03-03T06:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0002:sleep_core" + }, + { + "type": "SLEEP_DURATION", + "value": 90, + "unit": "minutes", + "measuredAt": "2026-03-03T06:00:00.000Z", + "fieldTag": "sleep_deep", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0002:sleep_deep" + }, + { + "type": "SLEEP_DURATION", + "value": 105, + "unit": "minutes", + "measuredAt": "2026-03-03T06:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "externalId": "sleep:sleep-0002:sleep_rem" + }, + { + "type": "SLEEP_DURATION", + "value": 30, + "unit": "minutes", + "measuredAt": "2026-03-03T06:00:00.000Z", + "fieldTag": "sleep_awake", + "sleepStage": "AWAKE", + "externalId": "sleep:sleep-0002:sleep_awake" + }, + { + "type": "SLEEP_EFFICIENCY", + "value": 88, + "unit": "%", + "measuredAt": "2026-03-03T06:00:00.000Z", + "fieldTag": "sleep_eff", + "externalId": "sleep:sleep-0002:sleep_eff" + } + ], + "retired": false + }, + { + "id": "sleep-0002", + "day": "2026-03-03", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 240, + "unit": "minutes", + "measuredAt": "2026-03-03T06:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0002:sleep_core" + }, + { + "type": "SLEEP_DURATION", + "value": 90, + "unit": "minutes", + "measuredAt": "2026-03-03T06:00:00.000Z", + "fieldTag": "sleep_deep", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0002:sleep_deep" + }, + { + "type": "SLEEP_DURATION", + "value": 105, + "unit": "minutes", + "measuredAt": "2026-03-03T06:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "externalId": "sleep:sleep-0002:sleep_rem" + }, + { + "type": "SLEEP_DURATION", + "value": 30, + "unit": "minutes", + "measuredAt": "2026-03-03T06:00:00.000Z", + "fieldTag": "sleep_awake", + "sleepStage": "AWAKE", + "externalId": "sleep:sleep-0002:sleep_awake" + }, + { + "type": "SLEEP_EFFICIENCY", + "value": 88, + "unit": "%", + "measuredAt": "2026-03-03T06:00:00.000Z", + "fieldTag": "sleep_eff", + "externalId": "sleep:sleep-0002:sleep_eff" + } + ], + "retired": false + }, + { + "id": "sleep-0002", + "day": "2026-03-03", + "zone": "America/New_York", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 240, + "unit": "minutes", + "measuredAt": "2026-03-03T06:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0002:sleep_core" + }, + { + "type": "SLEEP_DURATION", + "value": 90, + "unit": "minutes", + "measuredAt": "2026-03-03T06:00:00.000Z", + "fieldTag": "sleep_deep", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0002:sleep_deep" + }, + { + "type": "SLEEP_DURATION", + "value": 105, + "unit": "minutes", + "measuredAt": "2026-03-03T06:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "externalId": "sleep:sleep-0002:sleep_rem" + }, + { + "type": "SLEEP_DURATION", + "value": 30, + "unit": "minutes", + "measuredAt": "2026-03-03T06:00:00.000Z", + "fieldTag": "sleep_awake", + "sleepStage": "AWAKE", + "externalId": "sleep:sleep-0002:sleep_awake" + }, + { + "type": "SLEEP_EFFICIENCY", + "value": 88, + "unit": "%", + "measuredAt": "2026-03-03T06:00:00.000Z", + "fieldTag": "sleep_eff", + "externalId": "sleep:sleep-0002:sleep_eff" + } + ], + "retired": false + }, + { + "id": "sleep-0003", + "day": "2026-03-02", + "zone": null, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 25, + "unit": "minutes", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0003:sleep_core" + }, + { + "type": "SLEEP_DURATION", + "value": 0, + "unit": "minutes", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "sleep_deep", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0003:sleep_deep" + }, + { + "type": "SLEEP_DURATION", + "value": 0, + "unit": "minutes", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "sleep_awake", + "sleepStage": "AWAKE", + "externalId": "sleep:sleep-0003:sleep_awake" + }, + { + "type": "SLEEP_EFFICIENCY", + "value": 83.33, + "unit": "%", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "sleep_eff", + "externalId": "sleep:sleep-0003:sleep_eff" + }, + { + "type": "HRV_RMSSD", + "value": 35.13, + "unit": "ms", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "hrv_rmssd", + "externalId": "sleep:sleep-0003:hrv_rmssd" + }, + { + "type": "RESTING_HEART_RATE", + "value": 62, + "unit": "bpm", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "rhr", + "externalId": "sleep:sleep-0003:rhr" + }, + { + "type": "RESPIRATORY_RATE", + "value": 15, + "unit": "breaths/min", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "resp_rate", + "externalId": "sleep:sleep-0003:resp_rate" + } + ], + "retired": false + }, + { + "id": "sleep-0003", + "day": "2026-03-02", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 25, + "unit": "minutes", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0003:sleep_core" + }, + { + "type": "SLEEP_DURATION", + "value": 0, + "unit": "minutes", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "sleep_deep", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0003:sleep_deep" + }, + { + "type": "SLEEP_DURATION", + "value": 0, + "unit": "minutes", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "sleep_awake", + "sleepStage": "AWAKE", + "externalId": "sleep:sleep-0003:sleep_awake" + }, + { + "type": "SLEEP_EFFICIENCY", + "value": 83.33, + "unit": "%", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "sleep_eff", + "externalId": "sleep:sleep-0003:sleep_eff" + }, + { + "type": "HRV_RMSSD", + "value": 35.13, + "unit": "ms", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "hrv_rmssd", + "externalId": "sleep:sleep-0003:hrv_rmssd" + }, + { + "type": "RESTING_HEART_RATE", + "value": 62, + "unit": "bpm", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "rhr", + "externalId": "sleep:sleep-0003:rhr" + }, + { + "type": "RESPIRATORY_RATE", + "value": 15, + "unit": "breaths/min", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "resp_rate", + "externalId": "sleep:sleep-0003:resp_rate" + } + ], + "retired": false + }, + { + "id": "sleep-0003", + "day": "2026-03-02", + "zone": "America/New_York", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 25, + "unit": "minutes", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0003:sleep_core" + }, + { + "type": "SLEEP_DURATION", + "value": 0, + "unit": "minutes", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "sleep_deep", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0003:sleep_deep" + }, + { + "type": "SLEEP_DURATION", + "value": 0, + "unit": "minutes", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "sleep_awake", + "sleepStage": "AWAKE", + "externalId": "sleep:sleep-0003:sleep_awake" + }, + { + "type": "SLEEP_EFFICIENCY", + "value": 83.33, + "unit": "%", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "sleep_eff", + "externalId": "sleep:sleep-0003:sleep_eff" + }, + { + "type": "HRV_RMSSD", + "value": 35.13, + "unit": "ms", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "hrv_rmssd", + "externalId": "sleep:sleep-0003:hrv_rmssd" + }, + { + "type": "RESTING_HEART_RATE", + "value": 62, + "unit": "bpm", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "rhr", + "externalId": "sleep:sleep-0003:rhr" + }, + { + "type": "RESPIRATORY_RATE", + "value": 15, + "unit": "breaths/min", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "resp_rate", + "externalId": "sleep:sleep-0003:resp_rate" + } + ], + "retired": false + }, + { + "id": "sleep-0004", + "day": "2026-03-04", + "zone": null, + "rows": [], + "retired": true + }, + { + "id": "sleep-0004", + "day": "2026-03-04", + "zone": "Europe/Berlin", + "rows": [], + "retired": true + }, + { + "id": "sleep-0004", + "day": "2026-03-04", + "zone": "America/New_York", + "rows": [], + "retired": true + }, + { + "id": "sleep-0005", + "day": "2026-03-05", + "zone": null, + "rows": [], + "retired": true + }, + { + "id": "sleep-0005", + "day": "2026-03-05", + "zone": "Europe/Berlin", + "rows": [], + "retired": true + }, + { + "id": "sleep-0005", + "day": "2026-03-05", + "zone": "America/New_York", + "rows": [], + "retired": true + }, + { + "id": "sleep-0006", + "day": "2026-03-06", + "zone": null, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 200, + "unit": "minutes", + "measuredAt": "2026-03-06T12:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0006:sleep_core" + }, + { + "type": "SLEEP_DURATION", + "value": 80, + "unit": "minutes", + "measuredAt": "2026-03-06T12:00:00.000Z", + "fieldTag": "sleep_deep", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0006:sleep_deep" + }, + { + "type": "SLEEP_DURATION", + "value": 95, + "unit": "minutes", + "measuredAt": "2026-03-06T12:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "externalId": "sleep:sleep-0006:sleep_rem" + }, + { + "type": "SLEEP_DURATION", + "value": 15, + "unit": "minutes", + "measuredAt": "2026-03-06T12:00:00.000Z", + "fieldTag": "sleep_awake", + "sleepStage": "AWAKE", + "externalId": "sleep:sleep-0006:sleep_awake" + }, + { + "type": "RESPIRATORY_RATE", + "value": 13.8, + "unit": "breaths/min", + "measuredAt": "2026-03-06T12:00:00.000Z", + "fieldTag": "resp_rate", + "externalId": "sleep:sleep-0006:resp_rate" + } + ], + "retired": false + }, + { + "id": "sleep-0006", + "day": "2026-03-06", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 200, + "unit": "minutes", + "measuredAt": "2026-03-06T11:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0006:sleep_core" + }, + { + "type": "SLEEP_DURATION", + "value": 80, + "unit": "minutes", + "measuredAt": "2026-03-06T11:00:00.000Z", + "fieldTag": "sleep_deep", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0006:sleep_deep" + }, + { + "type": "SLEEP_DURATION", + "value": 95, + "unit": "minutes", + "measuredAt": "2026-03-06T11:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "externalId": "sleep:sleep-0006:sleep_rem" + }, + { + "type": "SLEEP_DURATION", + "value": 15, + "unit": "minutes", + "measuredAt": "2026-03-06T11:00:00.000Z", + "fieldTag": "sleep_awake", + "sleepStage": "AWAKE", + "externalId": "sleep:sleep-0006:sleep_awake" + }, + { + "type": "RESPIRATORY_RATE", + "value": 13.8, + "unit": "breaths/min", + "measuredAt": "2026-03-06T11:00:00.000Z", + "fieldTag": "resp_rate", + "externalId": "sleep:sleep-0006:resp_rate" + } + ], + "retired": false + }, + { + "id": "sleep-0006", + "day": "2026-03-06", + "zone": "America/New_York", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 200, + "unit": "minutes", + "measuredAt": "2026-03-06T17:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0006:sleep_core" + }, + { + "type": "SLEEP_DURATION", + "value": 80, + "unit": "minutes", + "measuredAt": "2026-03-06T17:00:00.000Z", + "fieldTag": "sleep_deep", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0006:sleep_deep" + }, + { + "type": "SLEEP_DURATION", + "value": 95, + "unit": "minutes", + "measuredAt": "2026-03-06T17:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "externalId": "sleep:sleep-0006:sleep_rem" + }, + { + "type": "SLEEP_DURATION", + "value": 15, + "unit": "minutes", + "measuredAt": "2026-03-06T17:00:00.000Z", + "fieldTag": "sleep_awake", + "sleepStage": "AWAKE", + "externalId": "sleep:sleep-0006:sleep_awake" + }, + { + "type": "RESPIRATORY_RATE", + "value": 13.8, + "unit": "breaths/min", + "measuredAt": "2026-03-06T17:00:00.000Z", + "fieldTag": "resp_rate", + "externalId": "sleep:sleep-0006:resp_rate" + } + ], + "retired": false + }, + { + "id": "sleep-0007", + "day": "2026-03-07", + "zone": null, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 10, + "unit": "minutes", + "measuredAt": "2026-03-07T04:35:00.000Z", + "fieldTag": "seg:2026-03-07T04:25:00.000Z", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0007:seg:2026-03-07T04:25:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 10, + "unit": "minutes", + "measuredAt": "2026-03-07T04:50:00.000Z", + "fieldTag": "seg:2026-03-07T04:40:00.000Z", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0007:seg:2026-03-07T04:40:00.000Z" + }, + { + "type": "SLEEP_EFFICIENCY", + "value": 85, + "unit": "%", + "measuredAt": "2026-03-07T05:00:00.000Z", + "fieldTag": "sleep_eff", + "externalId": "sleep:sleep-0007:sleep_eff" + } + ], + "retired": false + }, + { + "id": "sleep-0007", + "day": "2026-03-07", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 10, + "unit": "minutes", + "measuredAt": "2026-03-07T04:35:00.000Z", + "fieldTag": "seg:2026-03-07T04:25:00.000Z", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0007:seg:2026-03-07T04:25:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 10, + "unit": "minutes", + "measuredAt": "2026-03-07T04:50:00.000Z", + "fieldTag": "seg:2026-03-07T04:40:00.000Z", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0007:seg:2026-03-07T04:40:00.000Z" + }, + { + "type": "SLEEP_EFFICIENCY", + "value": 85, + "unit": "%", + "measuredAt": "2026-03-07T05:00:00.000Z", + "fieldTag": "sleep_eff", + "externalId": "sleep:sleep-0007:sleep_eff" + } + ], + "retired": false + }, + { + "id": "sleep-0007", + "day": "2026-03-07", + "zone": "America/New_York", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 10, + "unit": "minutes", + "measuredAt": "2026-03-07T04:35:00.000Z", + "fieldTag": "seg:2026-03-07T04:25:00.000Z", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0007:seg:2026-03-07T04:25:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 10, + "unit": "minutes", + "measuredAt": "2026-03-07T04:50:00.000Z", + "fieldTag": "seg:2026-03-07T04:40:00.000Z", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0007:seg:2026-03-07T04:40:00.000Z" + }, + { + "type": "SLEEP_EFFICIENCY", + "value": 85, + "unit": "%", + "measuredAt": "2026-03-07T05:00:00.000Z", + "fieldTag": "sleep_eff", + "externalId": "sleep:sleep-0007:sleep_eff" + } + ], + "retired": false + }, + { + "id": "sleep-0008", + "day": "2026-03-08", + "zone": null, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 220, + "unit": "minutes", + "measuredAt": "2026-03-08T07:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0008:sleep_core" + }, + { + "type": "SLEEP_DURATION", + "value": 100, + "unit": "minutes", + "measuredAt": "2026-03-08T07:00:00.000Z", + "fieldTag": "sleep_deep", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0008:sleep_deep" + }, + { + "type": "SLEEP_DURATION", + "value": 100, + "unit": "minutes", + "measuredAt": "2026-03-08T07:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "externalId": "sleep:sleep-0008:sleep_rem" + }, + { + "type": "SLEEP_DURATION", + "value": 20, + "unit": "minutes", + "measuredAt": "2026-03-08T07:00:00.000Z", + "fieldTag": "sleep_awake", + "sleepStage": "AWAKE", + "externalId": "sleep:sleep-0008:sleep_awake" + } + ], + "retired": false + }, + { + "id": "sleep-0008", + "day": "2026-03-08", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 220, + "unit": "minutes", + "measuredAt": "2026-03-08T07:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0008:sleep_core" + }, + { + "type": "SLEEP_DURATION", + "value": 100, + "unit": "minutes", + "measuredAt": "2026-03-08T07:00:00.000Z", + "fieldTag": "sleep_deep", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0008:sleep_deep" + }, + { + "type": "SLEEP_DURATION", + "value": 100, + "unit": "minutes", + "measuredAt": "2026-03-08T07:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "externalId": "sleep:sleep-0008:sleep_rem" + }, + { + "type": "SLEEP_DURATION", + "value": 20, + "unit": "minutes", + "measuredAt": "2026-03-08T07:00:00.000Z", + "fieldTag": "sleep_awake", + "sleepStage": "AWAKE", + "externalId": "sleep:sleep-0008:sleep_awake" + } + ], + "retired": false + }, + { + "id": "sleep-0008", + "day": "2026-03-08", + "zone": "America/New_York", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 220, + "unit": "minutes", + "measuredAt": "2026-03-08T07:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0008:sleep_core" + }, + { + "type": "SLEEP_DURATION", + "value": 100, + "unit": "minutes", + "measuredAt": "2026-03-08T07:00:00.000Z", + "fieldTag": "sleep_deep", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0008:sleep_deep" + }, + { + "type": "SLEEP_DURATION", + "value": 100, + "unit": "minutes", + "measuredAt": "2026-03-08T07:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "externalId": "sleep:sleep-0008:sleep_rem" + }, + { + "type": "SLEEP_DURATION", + "value": 20, + "unit": "minutes", + "measuredAt": "2026-03-08T07:00:00.000Z", + "fieldTag": "sleep_awake", + "sleepStage": "AWAKE", + "externalId": "sleep:sleep-0008:sleep_awake" + } + ], + "retired": false + }, + { + "id": "sleep-0009", + "day": "2026-03-09", + "zone": null, + "rows": [], + "retired": false + }, + { + "id": "sleep-0009", + "day": "2026-03-09", + "zone": "Europe/Berlin", + "rows": [], + "retired": false + }, + { + "id": "sleep-0009", + "day": "2026-03-09", + "zone": "America/New_York", + "rows": [], + "retired": false + }, + { + "id": "sleep-0010", + "day": "2026-03-10", + "zone": null, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 60.02, + "unit": "minutes", + "measuredAt": "2026-03-10T06:00:00.000Z", + "fieldTag": "sleep_deep", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0010:sleep_deep" + }, + { + "type": "SLEEP_DURATION", + "value": 66.67, + "unit": "minutes", + "measuredAt": "2026-03-10T06:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "externalId": "sleep:sleep-0010:sleep_rem" + }, + { + "type": "RESTING_HEART_RATE", + "value": 50, + "unit": "bpm", + "measuredAt": "2026-03-10T06:00:00.000Z", + "fieldTag": "rhr", + "externalId": "sleep:sleep-0010:rhr" + } + ], + "retired": false + }, + { + "id": "sleep-0010", + "day": "2026-03-10", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 60.02, + "unit": "minutes", + "measuredAt": "2026-03-10T06:00:00.000Z", + "fieldTag": "sleep_deep", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0010:sleep_deep" + }, + { + "type": "SLEEP_DURATION", + "value": 66.67, + "unit": "minutes", + "measuredAt": "2026-03-10T06:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "externalId": "sleep:sleep-0010:sleep_rem" + }, + { + "type": "RESTING_HEART_RATE", + "value": 50, + "unit": "bpm", + "measuredAt": "2026-03-10T06:00:00.000Z", + "fieldTag": "rhr", + "externalId": "sleep:sleep-0010:rhr" + } + ], + "retired": false + }, + { + "id": "sleep-0010", + "day": "2026-03-10", + "zone": "America/New_York", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 60.02, + "unit": "minutes", + "measuredAt": "2026-03-10T06:00:00.000Z", + "fieldTag": "sleep_deep", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0010:sleep_deep" + }, + { + "type": "SLEEP_DURATION", + "value": 66.67, + "unit": "minutes", + "measuredAt": "2026-03-10T06:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "externalId": "sleep:sleep-0010:sleep_rem" + }, + { + "type": "RESTING_HEART_RATE", + "value": 50, + "unit": "bpm", + "measuredAt": "2026-03-10T06:00:00.000Z", + "fieldTag": "rhr", + "externalId": "sleep:sleep-0010:rhr" + } + ], + "retired": false + }, + { + "id": "sleep-0011", + "day": "2026-03-11", + "zone": null, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 0, + "unit": "minutes", + "measuredAt": "2026-03-11T06:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0011:sleep_core" + }, + { + "type": "SLEEP_EFFICIENCY", + "value": 0, + "unit": "%", + "measuredAt": "2026-03-11T06:00:00.000Z", + "fieldTag": "sleep_eff", + "externalId": "sleep:sleep-0011:sleep_eff" + } + ], + "retired": false + }, + { + "id": "sleep-0011", + "day": "2026-03-11", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 0, + "unit": "minutes", + "measuredAt": "2026-03-11T06:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0011:sleep_core" + }, + { + "type": "SLEEP_EFFICIENCY", + "value": 0, + "unit": "%", + "measuredAt": "2026-03-11T06:00:00.000Z", + "fieldTag": "sleep_eff", + "externalId": "sleep:sleep-0011:sleep_eff" + } + ], + "retired": false + }, + { + "id": "sleep-0011", + "day": "2026-03-11", + "zone": "America/New_York", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 0, + "unit": "minutes", + "measuredAt": "2026-03-11T06:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0011:sleep_core" + }, + { + "type": "SLEEP_EFFICIENCY", + "value": 0, + "unit": "%", + "measuredAt": "2026-03-11T06:00:00.000Z", + "fieldTag": "sleep_eff", + "externalId": "sleep:sleep-0011:sleep_eff" + } + ], + "retired": false + } +] diff --git a/src/lib/oura/__tests__/__golden__/map-vo2-max.json b/src/lib/oura/__tests__/__golden__/map-vo2-max.json new file mode 100644 index 000000000..6a6e53dd8 --- /dev/null +++ b/src/lib/oura/__tests__/__golden__/map-vo2-max.json @@ -0,0 +1,138 @@ +[ + { + "id": "vo2-0001", + "day": "2026-03-02", + "zone": null, + "rows": [ + { + "type": "VO2_MAX", + "value": 42.3, + "unit": "mL/(kg·min)", + "measuredAt": "2026-03-02T00:00:00.000Z", + "fieldTag": "vo2_max" + } + ] + }, + { + "id": "vo2-0001", + "day": "2026-03-02", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "VO2_MAX", + "value": 42.3, + "unit": "mL/(kg·min)", + "measuredAt": "2026-03-02T00:00:00.000Z", + "fieldTag": "vo2_max" + } + ] + }, + { + "id": "vo2-0001", + "day": "2026-03-02", + "zone": "America/New_York", + "rows": [ + { + "type": "VO2_MAX", + "value": 42.3, + "unit": "mL/(kg·min)", + "measuredAt": "2026-03-02T00:00:00.000Z", + "fieldTag": "vo2_max" + } + ] + }, + { "id": "vo2-0002", "day": "2026-03-03", "zone": null, "rows": [] }, + { + "id": "vo2-0002", + "day": "2026-03-03", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "vo2-0002", + "day": "2026-03-03", + "zone": "America/New_York", + "rows": [] + }, + { "id": "vo2-0003", "day": "2026-03-04", "zone": null, "rows": [] }, + { + "id": "vo2-0003", + "day": "2026-03-04", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "vo2-0003", + "day": "2026-03-04", + "zone": "America/New_York", + "rows": [] + }, + { "id": "vo2-0004", "day": "2026-03-05", "zone": null, "rows": [] }, + { + "id": "vo2-0004", + "day": "2026-03-05", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "vo2-0004", + "day": "2026-03-05", + "zone": "America/New_York", + "rows": [] + }, + { + "id": "vo2-0005", + "day": "2026-03-06", + "zone": null, + "rows": [ + { + "type": "VO2_MAX", + "value": 38.76, + "unit": "mL/(kg·min)", + "measuredAt": "2026-03-06T12:00:00.000Z", + "fieldTag": "vo2_max" + } + ] + }, + { + "id": "vo2-0005", + "day": "2026-03-06", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "VO2_MAX", + "value": 38.76, + "unit": "mL/(kg·min)", + "measuredAt": "2026-03-06T11:00:00.000Z", + "fieldTag": "vo2_max" + } + ] + }, + { + "id": "vo2-0005", + "day": "2026-03-06", + "zone": "America/New_York", + "rows": [ + { + "type": "VO2_MAX", + "value": 38.76, + "unit": "mL/(kg·min)", + "measuredAt": "2026-03-06T17:00:00.000Z", + "fieldTag": "vo2_max" + } + ] + }, + { "id": "vo2-0006", "day": "2026-03-07", "zone": null, "rows": [] }, + { + "id": "vo2-0006", + "day": "2026-03-07", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "vo2-0006", + "day": "2026-03-07", + "zone": "America/New_York", + "rows": [] + } +] diff --git a/src/lib/oura/__tests__/__golden__/map-workouts.json b/src/lib/oura/__tests__/__golden__/map-workouts.json new file mode 100644 index 000000000..3c1fc6806 --- /dev/null +++ b/src/lib/oura/__tests__/__golden__/map-workouts.json @@ -0,0 +1,86 @@ +[ + { + "id": "workout-0001", + "row": { + "externalId": "workout-0001", + "sportType": "running", + "startedAt": "2026-03-02T06:00:00.000Z", + "endedAt": "2026-03-02T06:45:30.000Z", + "durationSec": 2730, + "totalEnergyKcal": 412, + "totalDistanceM": 7520.5, + "metadata": { + "ouraActivity": "running", + "ouraIntensity": "moderate", + "ouraSource": "confirmed" + } + } + }, + { + "id": "workout-0002", + "row": { + "externalId": "workout-0002", + "sportType": "other", + "startedAt": "2026-03-03T23:00:00.000Z", + "endedAt": "2026-03-03T23:30:00.000Z", + "durationSec": 1800, + "totalEnergyKcal": 121, + "totalDistanceM": null, + "metadata": { + "ouraActivity": "underwater_basket_weaving", + "ouraIntensity": "easy", + "ouraSource": "manual" + } + } + }, + { + "id": "workout-0003", + "row": { + "externalId": "workout-0003", + "sportType": "other", + "startedAt": "2026-03-04T12:00:00.000Z", + "endedAt": "2026-03-04T12:20:00.000Z", + "durationSec": 1200, + "totalEnergyKcal": null, + "totalDistanceM": 0, + "metadata": {} + } + }, + { "id": "workout-0004", "row": null }, + { "id": "", "row": null }, + { "id": "workout-0006", "row": null }, + { + "id": "workout-0007", + "row": { + "externalId": "workout-0007", + "sportType": "yoga", + "startedAt": "2026-03-08T06:30:00.000Z", + "endedAt": "2026-03-08T06:30:00.000Z", + "durationSec": 0, + "totalEnergyKcal": null, + "totalDistanceM": null, + "metadata": { + "ouraActivity": "Yoga", + "ouraIntensity": "hard", + "ouraSource": "autodetected" + } + } + }, + { + "id": "workout-0008", + "row": { + "externalId": "workout-0008", + "sportType": "strength", + "startedAt": "2026-03-09T17:00:00.250Z", + "endedAt": "2026-03-09T17:50:00.749Z", + "durationSec": 3000, + "totalEnergyKcal": 0, + "totalDistanceM": null, + "metadata": { + "ouraActivity": "strength_training", + "ouraSource": "workout_heart_rate" + } + } + }, + { "id": "workout-0009", "row": null } +] diff --git a/src/lib/oura/__tests__/client.test.ts b/src/lib/oura/__tests__/client.test.ts index 46d800995..05f49c478 100644 --- a/src/lib/oura/__tests__/client.test.ts +++ b/src/lib/oura/__tests__/client.test.ts @@ -1,19 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { - OURA_OAUTH_LEGACY_TOKEN_URL, - OURA_OAUTH_TOKEN_URL, - RESILIENCE_LEVELS, - derivePeriodDaysFromCyclePhases, - exchangeCode, fetchCardiovascularAge, fetchDailyCyclePhases, fetchReadiness, fetchResilience, fetchVo2Max, - getAuthorizationUrl, - getOuraCredentials, - isAcceptedOuraIssuer, +} from "../client"; +import { + RESILIENCE_LEVELS, + derivePeriodDaysFromCyclePhases, mapCardiovascularAge, mapDailyActivity, mapDailySleep, @@ -22,7 +18,8 @@ import { mapResilience, mapSleep, mapVo2Max, - refreshAccessToken, +} from "@/lib/oura/map"; +import { type OuraCardiovascularAge, type OuraDailyActivity, type OuraDailySleep, @@ -31,8 +28,15 @@ import { type OuraResilience, type OuraSleep, type OuraVo2Max, -} from "../client"; +} from "@/lib/oura/wire-types"; +import { + OURA_OAUTH_LEGACY_TOKEN_URL, + OURA_OAUTH_TOKEN_URL, + getOuraCredentials, + isAcceptedOuraIssuer, +} from "@/lib/oura/oauth"; import { OuraApiError } from "../response-classifier"; +import { ouraOAuth, exchangeOuraCode } from "../oauth"; const CREDS = { clientId: "cid", clientSecret: "csecret" }; @@ -75,7 +79,7 @@ describe("getOuraCredentials", () => { describe("getAuthorizationUrl", () => { it("builds the Oura authorize URL", () => { - const url = getAuthorizationUrl("signed", CREDS); + const url = ouraOAuth.authorizationUrl("signed", CREDS); expect(url).toContain("cloud.ouraring.com/oauth/authorize"); expect(url).toContain( "scope=daily+spo2+heart_health+stress+workout+heartrate+personal", @@ -92,7 +96,7 @@ describe("exchangeCode / refreshAccessToken", () => { body: { access_token: "a", refresh_token: "r", expires_in: 86400 }, }, ]); - const t = await exchangeCode("code", CREDS); + const t = await exchangeOuraCode("code", CREDS); expect(t.access_token).toBe("a"); expect(t.refresh_token).toBe("r"); }); @@ -104,7 +108,7 @@ describe("exchangeCode / refreshAccessToken", () => { body: { access_token: "a2", refresh_token: "r2", expires_in: 86400 }, }, ]); - const t = await refreshAccessToken("r1", CREDS); + const t = await ouraOAuth.refresh("r1", CREDS); expect(t.refresh_token).toBe("r2"); const [, init] = fetchMock.mock.calls[0]!; expect(init!.body).toContain("grant_type=refresh_token"); @@ -112,7 +116,7 @@ describe("exchangeCode / refreshAccessToken", () => { it("throws OuraApiError on a 400 invalid_grant", async () => { installFetchMock([{ status: 400, body: { error: "invalid_grant" } }]); - await expect(refreshAccessToken("dead", CREDS)).rejects.toBeInstanceOf( + await expect(ouraOAuth.refresh("dead", CREDS)).rejects.toBeInstanceOf( OuraApiError, ); }); @@ -122,7 +126,7 @@ describe("exchangeCode / refreshAccessToken", () => { // TypeError. It must surface as a classified transient integration error. it("classifies a 2xx with an empty body instead of casting null", async () => { installFetchMock([{ status: 200, body: null }]); - await expect(exchangeCode("code", CREDS)).rejects.toMatchObject({ + await expect(exchangeOuraCode("code", CREDS)).rejects.toMatchObject({ classification: "transient", reason: "empty_token_body", }); @@ -130,7 +134,7 @@ describe("exchangeCode / refreshAccessToken", () => { it("classifies a 2xx with a non-object body on refresh", async () => { installFetchMock([{ status: 200, body: "not-json" }]); - await expect(refreshAccessToken("r1", CREDS)).rejects.toBeInstanceOf( + await expect(ouraOAuth.refresh("r1", CREDS)).rejects.toBeInstanceOf( OuraApiError, ); }); @@ -153,7 +157,7 @@ describe("token endpoint order", () => { it("exchanges a code at the live endpoint, sending the secret in the body", async () => { const fetchMock = installFetchMock([OK]); - await exchangeCode("code", CREDS, MOI_ISS); + await exchangeOuraCode("code", CREDS, MOI_ISS); expect(OURA_OAUTH_TOKEN_URL).toBe( "https://moi.ouraring.com/oauth/v2/ext/oauth-token", ); @@ -169,7 +173,9 @@ describe("token endpoint order", () => { { status: 400, body: { error: "invalid_request" } }, OK, ]); - await expect(exchangeCode("code", CREDS, MOI_ISS)).rejects.toMatchObject({ + await expect( + exchangeOuraCode("code", CREDS, MOI_ISS), + ).rejects.toMatchObject({ upstreamError: "invalid_request", }); expect(urls(fetchMock)).toEqual([OURA_OAUTH_TOKEN_URL]); @@ -181,7 +187,7 @@ describe("token endpoint order", () => { { status: 401, body: { error: "invalid_client" } }, OK, ]); - const t = await exchangeCode("code", CREDS); + const t = await exchangeOuraCode("code", CREDS); expect(t.access_token).toBe("a"); expect(urls(fetchMock)).toEqual([ OURA_OAUTH_TOKEN_URL, @@ -191,13 +197,13 @@ describe("token endpoint order", () => { it("does not try the legacy endpoint once the live one accepted the code", async () => { const fetchMock = installFetchMock([OK]); - await exchangeCode("code", CREDS); + await exchangeOuraCode("code", CREDS); expect(urls(fetchMock)).toEqual([OURA_OAUTH_TOKEN_URL]); }); it("refreshes at the live endpoint first", async () => { const fetchMock = installFetchMock([OK]); - await refreshAccessToken("r1", CREDS); + await ouraOAuth.refresh("r1", CREDS); expect(urls(fetchMock)).toEqual([OURA_OAUTH_TOKEN_URL]); }); @@ -208,7 +214,7 @@ describe("token endpoint order", () => { "falls back to the legacy endpoint on a %i from the live one", async (status, body) => { const fetchMock = installFetchMock([{ status, body }, OK]); - const t = await refreshAccessToken("r1", CREDS); + const t = await ouraOAuth.refresh("r1", CREDS); expect(t.refresh_token).toBe("r"); expect(urls(fetchMock)).toEqual([ OURA_OAUTH_TOKEN_URL, @@ -222,7 +228,7 @@ describe("token endpoint order", () => { { status: 400, body: { error: "invalid_grant" } }, { status: 400, body: { error: "invalid_request" } }, ]); - await expect(refreshAccessToken("dead", CREDS)).rejects.toMatchObject({ + await expect(ouraOAuth.refresh("dead", CREDS)).rejects.toMatchObject({ upstreamError: "invalid_grant", }); }); @@ -230,7 +236,7 @@ describe("token endpoint order", () => { it("does not fall back on a server error or a rate limit", async () => { for (const status of [429, 503]) { const fetchMock = installFetchMock([{ status }, OK]); - await expect(refreshAccessToken("r1", CREDS)).rejects.toBeInstanceOf( + await expect(ouraOAuth.refresh("r1", CREDS)).rejects.toBeInstanceOf( OuraApiError, ); expect(urls(fetchMock)).toEqual([OURA_OAUTH_TOKEN_URL]); diff --git a/src/lib/oura/__tests__/credentials.test.ts b/src/lib/oura/__tests__/credentials.test.ts index 5e63fc22e..655a9495d 100644 --- a/src/lib/oura/__tests__/credentials.test.ts +++ b/src/lib/oura/__tests__/credentials.test.ts @@ -13,7 +13,7 @@ vi.mock("@/lib/crypto", () => ({ })); // The env fallback lives in ./client; stub it so the precedence is observable. -vi.mock("../client", () => ({ +vi.mock("../oauth", () => ({ getOuraCredentials: () => getOuraCredentials(), })); diff --git a/src/lib/oura/__tests__/map-fixtures.test.ts b/src/lib/oura/__tests__/map-fixtures.test.ts new file mode 100644 index 000000000..0e7ea9954 --- /dev/null +++ b/src/lib/oura/__tests__/map-fixtures.test.ts @@ -0,0 +1,201 @@ +/** + * What each Oura mapper makes of a fixed set of payloads, pinned in + * `__golden__/map-.json`. + * + * The fixtures under `__fixtures__/` are synthetic records in the documented + * Oura v2 wire shape, chosen to walk every branch the mappers have: a missing + * or zero score, a rejected or deleted sleep period, a nap beside the main + * sleep, a hypnogram with unknown digits, a record without its instant, an + * unparseable timestamp, an out-of-band heart rate, an unknown activity. Every + * mapper that takes a zone runs without one and under two explicit zones, so + * the date-only anchors are recorded where they land. + * + * The golden was written before the mappers moved out of `client.ts`, so a + * green run is the proof that the move changed nothing. + */ +import { describe, expect, it } from "vitest"; +import { format } from "prettier"; + +import { + derivePeriodDaysFromCyclePhases, + isRetiredOuraSleepPeriod, + mapCardiovascularAge, + mapDailyActivity, + mapDailySleep, + mapDailySpo2, + mapHeartRateSample, + mapReadiness, + mapResilience, + mapSleep, + mapVo2Max, + type MappedMeasurement, +} from "@/lib/oura/map"; +import { + type OuraCardiovascularAge, + type OuraCyclePhase, + type OuraDailyActivity, + type OuraDailySleep, + type OuraDailySpo2, + type OuraHeartRateSample, + type OuraReadiness, + type OuraResilience, + type OuraSleep, + type OuraVo2Max, + type OuraWorkout, +} from "@/lib/oura/wire-types"; +import { mapOuraWorkout } from "../sync-workouts"; + +import cardiovascularAgeFixture from "./__fixtures__/cardiovascular-age.json"; +import cyclePhasesFixture from "./__fixtures__/cycle-phases.json"; +import dailyActivityFixture from "./__fixtures__/daily-activity.json"; +import dailySleepFixture from "./__fixtures__/daily-sleep.json"; +import dailySpo2Fixture from "./__fixtures__/daily-spo2.json"; +import heartRateFixture from "./__fixtures__/heartrate.json"; +import readinessFixture from "./__fixtures__/readiness.json"; +import resilienceFixture from "./__fixtures__/resilience.json"; +import sleepFixture from "./__fixtures__/sleep.json"; +import vo2MaxFixture from "./__fixtures__/vo2-max.json"; +import workoutsFixture from "./__fixtures__/workouts.json"; + +/** The golden is written in the repository's JSON format, so the format check passes on it unchanged. */ +async function golden(value: unknown): Promise { + return format(JSON.stringify(value), { parser: "json" }); +} + +/** No zone (the mapper's own default), then two zones on either side of UTC. */ +const ZONES = [undefined, "Europe/Berlin", "America/New_York"] as const; + +/** Runs a zone-aware mapper over every record under every zone. */ +function acrossZones( + records: readonly T[], + map: (record: T, tz?: string) => MappedMeasurement[], +) { + return records.flatMap((record) => + ZONES.map((zone) => ({ + id: record.id ?? null, + day: record.day, + zone: zone ?? null, + rows: map(record, zone), + })), + ); +} + +describe("Oura mappers against the golden fixtures", () => { + it("maps daily readiness", async () => { + const records: OuraReadiness[] = readinessFixture; + await expect( + await golden(acrossZones(records, mapReadiness)), + ).toMatchFileSnapshot("./__golden__/map-readiness.json"); + }); + + it("maps sleep periods and flags retired ones", async () => { + const records: OuraSleep[] = sleepFixture; + const output = acrossZones(records, mapSleep).map((entry) => ({ + ...entry, + retired: isRetiredOuraSleepPeriod( + records.find((r) => r.id === entry.id) ?? {}, + ), + })); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-sleep.json", + ); + }); + + it("classifies every sleep type as retired or kept", async () => { + const types = [ + "long_sleep", + "sleep", + "late_nap", + "rest", + "deleted", + "REST", + "", + null, + undefined, + ]; + const output = types.map((type) => ({ + type: type ?? null, + retired: isRetiredOuraSleepPeriod({ type }), + })); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-sleep-retired.json", + ); + }); + + it("maps the daily sleep score", async () => { + const records: OuraDailySleep[] = dailySleepFixture; + await expect( + await golden(acrossZones(records, mapDailySleep)), + ).toMatchFileSnapshot("./__golden__/map-daily-sleep.json"); + }); + + it("maps daily SpO2", async () => { + const records: OuraDailySpo2[] = dailySpo2Fixture; + await expect( + await golden(acrossZones(records, mapDailySpo2)), + ).toMatchFileSnapshot("./__golden__/map-daily-spo2.json"); + }); + + it("maps daily activity", async () => { + const records: OuraDailyActivity[] = dailyActivityFixture; + await expect( + await golden(acrossZones(records, mapDailyActivity)), + ).toMatchFileSnapshot("./__golden__/map-daily-activity.json"); + }); + + it("maps VO2 max", async () => { + const records: OuraVo2Max[] = vo2MaxFixture; + await expect( + await golden(acrossZones(records, mapVo2Max)), + ).toMatchFileSnapshot("./__golden__/map-vo2-max.json"); + }); + + it("maps cardiovascular age", async () => { + const records: OuraCardiovascularAge[] = cardiovascularAgeFixture; + await expect( + await golden(acrossZones(records, mapCardiovascularAge)), + ).toMatchFileSnapshot("./__golden__/map-cardiovascular-age.json"); + }); + + it("maps the resilience level", async () => { + const records: OuraResilience[] = resilienceFixture; + await expect( + await golden(acrossZones(records, mapResilience)), + ).toMatchFileSnapshot("./__golden__/map-resilience.json"); + }); + + it("maps heart-rate samples", async () => { + const samples: OuraHeartRateSample[] = heartRateFixture; + const output = samples.map((sample) => ({ + timestamp: sample.timestamp ?? null, + bpm: sample.bpm ?? null, + row: mapHeartRateSample(sample), + })); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-heartrate.json", + ); + }); + + it("derives period days from cycle phases", async () => { + const scenarios: Array<{ scenario: string; records: OuraCyclePhase[] }> = + cyclePhasesFixture; + const output = scenarios.map(({ scenario, records }) => ({ + scenario, + periodDays: derivePeriodDaysFromCyclePhases(records), + })); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-cycle-phases.json", + ); + }); + + it("maps workouts", async () => { + const records: OuraWorkout[] = workoutsFixture; + const output = records.map((record) => ({ + id: record.id, + row: mapOuraWorkout(record), + })); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-workouts.json", + ); + }); +}); diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index cd2de619b..cfaa1ac78 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -125,7 +125,7 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({ }, })); -vi.mock("@/lib/insights/comprehensive-generate", () => ({ +vi.mock("@/lib/insights/status-invalidation", () => ({ invalidateStatusInsightsForTypes: invalidateMock, })); @@ -148,7 +148,13 @@ vi.mock("../client", async (importOriginal) => { fetchDailyCyclePhases: fetchCyclePhasesMock, fetchWorkouts: fetchWorkoutsMock, forEachHeartRatePage: heartRatePagesMock, - refreshAccessToken: refreshMock, + }; +}); +vi.mock("../oauth", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ouraOAuth: { ...actual.ouraOAuth, refresh: refreshMock }, }; }); diff --git a/src/lib/oura/__tests__/workouts-heart-rate.test.ts b/src/lib/oura/__tests__/workouts-heart-rate.test.ts index 7460e99f1..ba70242bd 100644 --- a/src/lib/oura/__tests__/workouts-heart-rate.test.ts +++ b/src/lib/oura/__tests__/workouts-heart-rate.test.ts @@ -33,11 +33,11 @@ vi.mock("@/lib/rollups/measurement-rollups", async (importOriginal) => ({ >()), recomputeBucketsForKeys: vi.fn(async () => {}), })); -vi.mock("@/lib/insights/comprehensive-generate", () => ({ +vi.mock("@/lib/insights/status-invalidation", () => ({ invalidateStatusInsightsForTypes: vi.fn(async () => {}), })); -import { mapHeartRateSample } from "../client"; +import { mapHeartRateSample } from "@/lib/oura/map"; import { OuraApiError } from "../response-classifier"; import { mapOuraSportType } from "../sport-map"; import { diff --git a/src/lib/oura/client.ts b/src/lib/oura/client.ts index 2e3ac2569..4696679bc 100644 --- a/src/lib/oura/client.ts +++ b/src/lib/oura/client.ts @@ -1,462 +1,35 @@ /** - * v1.17.0 — Oura Cloud API v2 client for OAuth + data fetching. + * Oura Cloud API v2 transport and endpoints: hand-rolled fetch over + * `safeFetch` (no SDK), collection reads with `next_token` pagination, read + * by `start_date` / `end_date` (YYYY-MM-DD). * Docs: https://cloud.ouraring.com/v2/docs (re-verify at build). * - * Mirrors the WHOOP / Polar client structure: hand-rolled fetch over - * `safeFetch` (no SDK), an OAuth handshake (`getAuthorizationUrl` / - * `exchangeCode` / `refreshAccessToken`), typed v2 collection fetchers with - * `next_token` pagination, and a single source-of-truth field→Measurement - * mapping. - * - * Oura specifics: standard authorization-code OAuth WITH refresh tokens (the - * sync layer refreshes on 401, persisting BOTH rotated tokens). The token - * endpoint takes client_id/secret in the body (client_secret_post) and lives - * on moi.ouraring.com, with the documented api.ouraring.com endpoint as a - * fallback for older apps. Collections are read by - * `start_date` / `end_date` (YYYY-MM-DD) and page via `next_token`. + * The wire shapes are `./wire-types.ts`, the record → measurement mapping is + * `./map.ts`, the OAuth handshake is `./oauth.ts`. */ import { getEvent } from "@/lib/logging/context"; -import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz"; -import { dayDiff } from "@/lib/cycle/day-math"; import { safeFetch } from "@/lib/safe-fetch"; import { OuraApiError, classifyOuraResponse } from "./response-classifier"; -import { envOr, envValue } from "@/lib/env"; -import { importValue } from "@/lib/integrations/import-value"; + import { walkPages } from "@/lib/integrations/page-walk"; import { retryAfterMsOf } from "@/lib/integrations/oauth-kit/retry-after"; -import { OURA_OAUTH_SCOPE, parseOuraMissingScope } from "./scope"; +import { parseOuraMissingScope } from "./scope"; +import type { + OuraCollection, + OuraSleep, + OuraReadiness, + OuraDailySleep, + OuraDailySpo2, + OuraVo2Max, + OuraCardiovascularAge, + OuraResilience, + OuraCyclePhase, + OuraDailyActivity, + OuraWorkout, + OuraHeartRateSample, +} from "./wire-types"; const OURA_API_BASE = "https://api.ouraring.com"; -const OURA_OAUTH_AUTH_URL = "https://cloud.ouraring.com/oauth/authorize"; - -/** - * Oura's live token endpoint. Apps created in the current developer portal - * get their codes from this authorization server (the callback carries - * `iss=https://moi.ouraring.com/oauth/v2/ext/oauth-anonymous`), and the - * documented `api.ouraring.com/oauth/token` answers those codes with - * `400 invalid_request`. Older apps are served here too. - */ -export const OURA_OAUTH_TOKEN_URL = - "https://moi.ouraring.com/oauth/v2/ext/oauth-token"; - -/** - * The documented endpoint, kept only as a fallback for the older apps the - * live endpoint turns away. Never tried first for a code: an authorization - * code works once (RFC 6749 4.1.2), so a rejected first attempt would burn - * it before the right endpoint saw it. - */ -export const OURA_OAUTH_LEGACY_TOKEN_URL = - "https://api.ouraring.com/oauth/token"; - -/** The origin of Oura's current authorization server. */ -const OURA_MOI_ORIGIN = "https://moi.ouraring.com"; - -/** - * Authorization-server origins an `iss` callback parameter may name - * (RFC 9207). Anything else is a mix-up and refused. - */ -const OURA_ISSUER_ORIGINS = new Set([ - OURA_MOI_ORIGIN, - "https://cloud.ouraring.com", - "https://api.ouraring.com", -]); - -/** - * Check the `iss` the authorization server sent with the callback. Absent is - * fine (older apps never send one); present, it must name one of Oura's own - * authorization servers. - */ -export function isAcceptedOuraIssuer(iss: string | null): boolean { - if (iss === null) return true; - try { - const url = new URL(iss); - return url.protocol === "https:" && OURA_ISSUER_ORIGINS.has(url.origin); - } catch { - return false; - } -} - -/** True when the code was issued by the current authorization server. */ -function issuedByMoi(iss: string | null | undefined): boolean { - if (!iss) return false; - try { - return new URL(iss).origin === OURA_MOI_ORIGIN; - } catch { - return false; - } -} - -export interface OuraCredentials { - clientId: string; - clientSecret: string; -} - -/** - * Env-configured shared OAuth credentials — the FALLBACK app used when a user - * has not registered their own BYO Oura client id/secret. As of v1.17.1 the - * per-user resolver `getOuraClientCredentials` (DB-first) is the primary path - * and calls this only when no per-user pair is stored. - */ -export function getOuraCredentials(): OuraCredentials | null { - const clientId = process.env.OURA_CLIENT_ID; - const clientSecret = process.env.OURA_CLIENT_SECRET; - if (!clientId || !clientSecret) return null; - return { clientId, clientSecret }; -} - -export function getOuraRedirectUri(): string { - // An empty or blank value counts as unset (the compose whitelist - // materialises an unset var as an empty string). - return envOr( - "OURA_REDIRECT_URI", - `${envValue("NEXT_PUBLIC_APP_URL")}/api/oura/callback`, - ); -} - -/** - * The consent-screen URL. The scopes come from `./scope`, which maps every - * collection HealthLog reads to the scope Oura enforces for it. A grant issued - * before a scope was added keeps the old set until the person reconnects once; - * the sync records what it lacks (`syncOuraScopeDenied`). - */ -export function getAuthorizationUrl( - state: string, - creds: OuraCredentials, -): string { - const params = new URLSearchParams({ - response_type: "code", - client_id: creds.clientId, - redirect_uri: getOuraRedirectUri(), - scope: OURA_OAUTH_SCOPE, - state, - }); - return `${OURA_OAUTH_AUTH_URL}?${params}`; -} - -export interface OuraTokenResponse { - access_token: string; - refresh_token: string; - expires_in: number; - token_type?: string; - scope?: string; -} - -interface TokenAttempt { - status: number; - json: unknown; -} - -async function requestToken( - url: string, - params: URLSearchParams, - verb: string, -): Promise { - const start = performance.now(); - const res = await safeFetch(url, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: params.toString(), - }); - const json: unknown = await res.json().catch(() => null); - const verdict = classifyOuraResponse(res.status); - getEvent()?.addExternalCall({ - service: "oura", - method: verb, - duration_ms: Math.round(performance.now() - start), - status: res.status, - error: verdict.classification === "success" ? undefined : verdict.reason, - }); - return { status: res.status, json }; -} - -function upstreamError(json: unknown): string | undefined { - if (json === null || typeof json !== "object") return undefined; - const error = (json as { error?: unknown }).error; - return typeof error === "string" ? error : undefined; -} - -/** - * Whether the legacy endpoint is worth asking after the live one refused: - * a 400, 401 or 404. The live endpoint may not know an older app's client, - * code or refresh token at all, and its refusal does not tell "unknown here" - * apart from "bad grant" reliably, so every such refusal gets the one retry. - * Server errors and rate limits do not: they say nothing about the grant. - */ -const FALLBACK_STATUSES = new Set([400, 401, 404]); - -function belongsElsewhere(attempt: TokenAttempt): boolean { - return FALLBACK_STATUSES.has(attempt.status); -} - -function tokenResult(attempt: TokenAttempt, verb: string): OuraTokenResponse { - const verdict = classifyOuraResponse(attempt.status); - if (verdict.classification !== "success") { - throw new OuraApiError({ - verb, - classification: verdict.classification, - httpStatus: verdict.httpStatus, - reason: verdict.reason, - upstreamError: upstreamError(attempt.json), - }); - } - // A 2xx with an empty or non-JSON body (captive portal, gateway 200, 204) - // leaves `json` null. Casting it straight to the token type handed callers a - // null they then dereferenced, surfacing an unclassified TypeError instead of - // a handled integration error. Narrow explicitly and classify as transient. - if (attempt.json === null || typeof attempt.json !== "object") { - throw new OuraApiError({ - verb, - classification: "transient", - httpStatus: attempt.status, - reason: "empty_token_body", - }); - } - return attempt.json as OuraTokenResponse; -} - -/** - * Ask the live endpoint, then the legacy one when `mayFallBack` allows it and - * the live endpoint refused with 400, 401 or 404. The legacy endpoint is - * never asked first. - */ -async function postToken( - params: URLSearchParams, - verb: string, - mayFallBack: boolean, -): Promise { - const live = await requestToken(OURA_OAUTH_TOKEN_URL, params, verb); - if (mayFallBack && belongsElsewhere(live)) { - const legacy = await requestToken( - OURA_OAUTH_LEGACY_TOKEN_URL, - params, - `${verb}.legacy`, - ); - if (legacy.status >= 200 && legacy.status < 300) { - return tokenResult(legacy, verb); - } - // Neither endpoint took it. The live endpoint's answer is the one that - // describes the grant for every current app, so report that one. - } - return tokenResult(live, verb); -} - -/** - * Exchange an authorization code. `iss` is the callback's issuer parameter: - * a code from the current authorization server goes to the live endpoint - * only, since a second attempt elsewhere could never succeed. Without an - * issuer (an older app) the legacy endpoint is the fallback after the live - * one has refused the code; the live endpoint is always asked first. - */ -export async function exchangeCode( - code: string, - creds: OuraCredentials, - iss?: string | null, -): Promise { - return postToken( - new URLSearchParams({ - grant_type: "authorization_code", - code, - redirect_uri: getOuraRedirectUri(), - client_id: creds.clientId, - client_secret: creds.clientSecret, - }), - "exchangeCode", - !issuedByMoi(iss), - ); -} - -/** - * Refresh an access token: live endpoint first, the legacy endpoint when the - * live one refuses it (a refresh token of an older app). - * A refused refresh does not consume the token, so the retry is safe. - */ -export async function refreshAccessToken( - refreshToken: string, - creds: OuraCredentials, -): Promise { - return postToken( - new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: creds.clientId, - client_secret: creds.clientSecret, - }), - "refreshAccessToken", - true, - ); -} - -// ─── Collection reads (v2) ───────────────────────────────────── - -/** v2 paginated envelope: `{ data, next_token }`. */ -export interface OuraCollection { - data: T[]; - next_token?: string | null; -} - -export interface OuraSleep { - id: string; - day: string; - /** - * `long_sleep` | `sleep` | `late_nap` | `rest` | `deleted`. `rest` is a - * detected period the person rejected and `deleted` one they removed; see - * `isRetiredOuraSleepPeriod`. - */ - type?: string | null; - bedtime_start?: string; - bedtime_end?: string; - total_sleep_duration?: number | null; - time_in_bed?: number | null; - efficiency?: number | null; - rem_sleep_duration?: number | null; - deep_sleep_duration?: number | null; - light_sleep_duration?: number | null; - awake_time?: number | null; - average_heart_rate?: number | null; - lowest_heart_rate?: number | null; - average_hrv?: number | null; - average_breath?: number | null; - /** - * 5-minute hypnogram: one digit per 5-min interval from `bedtime_start`. - * Oura's measured per-stage timeline — `1`=deep, `2`=light, `3`=rem, - * `4`=awake. Re-verify the digit→stage mapping against - * `cloud.ouraring.com/v2/docs` at build time (the encoding is published in - * the `Sleep` model; cross-checked against the `oura-api`/`@pinta365/oura-api` - * client libraries). Unlike WHOOP — which exposes only stage totals and must - * synthesise an order — Oura gives the real onsets, so we emit timed - * per-segment rows (`reconstructed: false`). - */ - sleep_phase_5_min?: string | null; -} - -/** - * Oura `daily_readiness` contributors — each a 1–100 sub-score that explains - * the headline readiness number. All optional (a record may predate a - * contributor or carry a null for a missing input). - */ -export interface OuraReadinessContributors { - activity_balance?: number | null; - body_temperature?: number | null; - hrv_balance?: number | null; - previous_day_activity?: number | null; - previous_night?: number | null; - recovery_index?: number | null; - resting_heart_rate?: number | null; - sleep_balance?: number | null; -} - -export interface OuraReadiness { - id: string; - day: string; - timestamp?: string; - score?: number | null; - contributors?: OuraReadinessContributors | null; - /** Nightly body-temperature deviation from the personal baseline, °C (signed). */ - temperature_deviation?: number | null; - /** Long-term body-temperature trend deviation, °C (signed). */ - temperature_trend_deviation?: number | null; -} - -/** Oura `daily_sleep` — the headline Sleep Score (distinct from `sleep` detail). */ -export interface OuraDailySleep { - id: string; - day: string; - timestamp?: string; - score?: number | null; -} - -/** Oura `daily_spo2` — average overnight blood-oxygen saturation. */ -export interface OuraDailySpo2 { - id: string; - day: string; - spo2_percentage?: { average?: number | null } | null; -} - -/** Oura `vO2_max` — the dedicated cardio-fitness collection, mL/(kg·min). */ -export interface OuraVo2Max { - id: string; - day: string; - timestamp?: string; - vo2_max?: number | null; -} - -/** - * Oura `daily_cardiovascular_age` — Oura's estimate of how the user's - * cardiovascular system is ageing relative to chronological age, in years. - * Maps cleanly onto the existing `VASCULAR_AGE` enum (the same years-unit - * arterial-age concept Withings' Body Scan reports under meastype 155). - */ -export interface OuraCardiovascularAge { - id?: string; - day: string; - /** Estimated vascular age in years. */ - vascular_age?: number | null; -} - -/** - * Oura `daily_resilience` — the daily resilience LEVEL, a categorical band - * describing how well the body copes with cumulative load. The headline is the - * `level` string (limited / adequate / solid / strong / exceptional); the - * collection also carries `contributors` (sleep_recovery / daytime_recovery / - * stress) which we do NOT ingest — we capture only the headline level. - * Re-verify the `level` field name + value set against - * `cloud.ouraring.com/v2/docs` at build time. - */ -export interface OuraResilience { - id?: string; - day: string; - /** limited | adequate | solid | strong | exceptional */ - level?: string | null; -} - -/** - * Oura `daily_cycle_phases` — the per-day Cycle Insights phase estimate - * (temperature-deviation-derived). UNDOCUMENTED in the published OpenAPI - * spec at `cloud.ouraring.com/v2/docs` as of this writing — the route was - * confirmed live against the production API (`GET - * /v2/usercollection/daily_cycle_phases` 400s "Token is missing" identically - * to every documented `daily_*` collection when called unauthenticated, vs a - * genuine 404 for an unknown path) but sits outside the spec dump alongside - * every other Oura reproductive-health resource (`period_start`, - * `pregnancy`, `fertile_window`, `ovulation_confirmed`, `blood_glucose` — all - * listed as valid webhook subscription data types, none with a documented - * read schema). These look gated behind an approval Oura does not extend to - * a self-registered OAuth app by default, so a 403/404 here is the EXPECTED - * outcome for most connections, not an anomaly — see `syncUserOuraCyclePhases` - * in `./cycle-sync` for how that is swallowed without touching the rest of - * the Oura connection's health status. Re-verify the `phase` field name + - * value set against `cloud.ouraring.com/v2/docs` once Oura documents it. - */ -export interface OuraCyclePhase { - id?: string; - day: string; - /** - * Oura's public Cycle Insights copy describes exactly two ring-observable - * phases (`follicular` / `luteal`, temperature-derived); some algorithm - * versions may additionally emit `menstrual` directly. Kept as a loose - * string — an unrecognised / missing value is skipped, never guessed (see - * `derivePeriodDaysFromCyclePhases`). - */ - phase?: string | null; -} - -export interface OuraDailyActivity { - id: string; - day: string; - timestamp?: string; - steps?: number | null; - active_calories?: number | null; - total_calories?: number | null; - /** - * Oura's activity-equivalent distance, metres: what a walk of the day's - * effort would have covered. Not distance travelled; never imported (see - * `mapDailyActivity`). - */ - equivalent_walking_distance?: number | null; -} interface DateRangeQuery { startDate: string; // YYYY-MM-DD @@ -763,28 +336,6 @@ export function fetchDailyCyclePhases( ); } -/** - * Oura `workout` — one activity session, auto-detected or entered. `activity` - * is Oura's own lowercase label (`walking`, `cycling`, `strength_training`, - * ...); `distance` is metres actually covered (a GPS or step-derived figure, - * unlike the daily activity-equivalent distance); `calories` is kcal. - */ -export interface OuraWorkout { - id: string; - day?: string; - activity?: string | null; - calories?: number | null; - distance?: number | null; - start_datetime: string; - end_datetime: string; - /** `easy` | `moderate` | `hard` */ - intensity?: string | null; - /** `manual` | `autodetected` | `confirmed` | `workout_heart_rate` */ - source?: string | null; - /** A name the person gave the workout. Free text; never imported. */ - label?: string | null; -} - export function fetchWorkouts( accessToken: string, query: DateRangeQuery, @@ -797,14 +348,6 @@ export function fetchWorkouts( ); } -/** One `heartrate` sample: a reading at an instant, tagged with its context. */ -export interface OuraHeartRateSample { - bpm?: number | null; - /** `awake` | `rest` | `sleep` | `session` | `live` | `workout` */ - source?: string | null; - timestamp?: string | null; -} - /** * Stream the `heartrate` collection for a time range, one page at a time. * Oura answers it by instant (`start_datetime` / `end_datetime`) and pages @@ -824,537 +367,3 @@ export function forEachHeartRatePage( onPage, ); } - -// ─── Field → Measurement mapping ─────────────────────────────── - -const SEC_TO_MIN = 1 / 60; - -export interface MappedMeasurement { - type: string; - value: number; - unit: string; - measuredAt: Date; - fieldTag: string; - sleepStage?: "CORE" | "DEEP" | "REM" | "AWAKE"; - /** - * Per-row externalId override. Most rows derive their externalId from the - * resource prefix + day + fieldTag in the sync layer, but the per-segment - * sleep-timeline rows (and any other row that needs a record-scoped key) - * carry their own here so several rows of one night stay distinct under - * `userId_type_source_externalId`. The sync layer uses `m.externalId ??` - * the default key. - */ - externalId?: string; -} - -function dayAnchor( - day: string, - tz: string | undefined, - bedtimeEnd?: string, -): Date | null { - // Prefer the precise wake instant when present; else anchor the date-only - // day at local noon in the user's zone (canonicalDailyTimestamp), which - // reads the same calendar day there. Noon UTC, the previous anchor, is - // already the next day from UTC+13 on. - if (bedtimeEnd) { - const d = new Date(bedtimeEnd); - return Number.isNaN(d.getTime()) ? null : d; - } - const d = canonicalDailyTimestamp(day, tz); - return Number.isNaN(d.getTime()) ? null : d; -} - -/** - * Map one Oura readiness record: - * - `score` → `RECOVERY_SCORE` (source OURA, feeding the recovery ladder - * below WHOOP). - * - `temperature_deviation` → `BODY_TEMPERATURE_DEVIATION` (signed °C offset - * from the personal baseline — illness / luteal-phase / stress signal). - * - * The eight readiness `contributors` are the *why* behind the score; they are - * carried back to the caller separately (not minted as Measurement rows here - * since each is a sub-score, not a stored metric) — see the dropped-field note. - * `temperature_trend_deviation` is the long-term trend twin; we keep only the - * nightly deviation as the actionable signal. - */ -export function mapReadiness( - r: OuraReadiness, - tz?: string, -): MappedMeasurement[] { - const measuredAt = dayAnchor(r.day, tz, r.timestamp); - if (!measuredAt) return []; - const out: MappedMeasurement[] = []; - if (typeof r.score === "number") { - out.push({ - type: "RECOVERY_SCORE", - value: importValue(r.score), - unit: "score", - measuredAt, - fieldTag: "recovery", - }); - } - if (typeof r.temperature_deviation === "number") { - out.push({ - type: "BODY_TEMPERATURE_DEVIATION", - value: importValue(r.temperature_deviation), - unit: "celsius", - measuredAt, - fieldTag: "temp_deviation", - }); - } - return out; -} - -/** - * Oura 5-minute hypnogram digit → our `SleepStage`. Oura encodes one digit per - * 5-min interval from `bedtime_start`: `1`=deep, `2`=light, `3`=rem, `4`=awake. - * Light maps to our CORE stage (the granular-asleep stage the sleep-night - * reader scores), matching the `light_sleep_duration → CORE` convention the - * stage-totals path already uses. Re-verify against `cloud.ouraring.com/v2/docs`. - */ -const HYPNOGRAM_STAGE: Record< - string, - NonNullable -> = { - "1": "DEEP", - "2": "CORE", - "3": "REM", - "4": "AWAKE", -}; - -/** Each hypnogram digit covers 5 minutes. */ -const HYPNOGRAM_INTERVAL_MIN = 5; -const HYPNOGRAM_INTERVAL_MS = HYPNOGRAM_INTERVAL_MIN * 60_000; - -/** - * Build timed per-segment `SLEEP_DURATION` rows from Oura's real measured - * `sleep_phase_5_min` hypnogram. Consecutive identical digits collapse into one - * segment; each segment is stamped at its own END instant (start + duration) - * relative to `bedtime_start`, so the sleep-night reader (which derives - * `start = end − value·60_000`) reconstructs the actual clock-time timeline — - * NOT the degraded all-on-`bedtime_end` shape. The rows are `reconstructed: - * false` by virtue of OURA being absent from the reader's - * `RECONSTRUCTED_TIMELINE_SOURCES` set (WHOOP only). Returns `null` when the - * hypnogram is absent / unusable so the caller falls back to stage totals. - */ -function mapSleepTimeline(s: OuraSleep): MappedMeasurement[] | null { - const hyp = s.sleep_phase_5_min; - if (typeof hyp !== "string" || hyp.length === 0 || !s.bedtime_start) { - return null; - } - const onset = new Date(s.bedtime_start); - if (Number.isNaN(onset.getTime())) return null; - - const out: MappedMeasurement[] = []; - let i = 0; - while (i < hyp.length) { - const digit = hyp[i]; - const stage = HYPNOGRAM_STAGE[digit]; - // Walk the run of identical digits. - let run = 1; - while (i + run < hyp.length && hyp[i + run] === digit) run += 1; - if (stage) { - const segStartMs = onset.getTime() + i * HYPNOGRAM_INTERVAL_MS; - const segEndMs = segStartMs + run * HYPNOGRAM_INTERVAL_MS; - // Keyed by the run's own START instant, NOT a positional run index. A - // revised hypnogram re-segments the night (runs merge/split), so an - // index shifted every following run onto the wrong row — wrong - // boundaries updated in place plus phantom tail rows the night-total - // double-counted. A run's start instant is a stable coordinate: an - // unchanged run keeps its id (re-classification overwrites in place), - // and whatever the revision orphaned is swept by the record-scoped - // cleanup in the sync (which also clears legacy `seg:` rows). - const fieldTag = `seg:${new Date(segStartMs).toISOString()}`; - out.push({ - type: "SLEEP_DURATION", - value: importValue(run * HYPNOGRAM_INTERVAL_MIN), - unit: "minutes", - measuredAt: new Date(segEndMs), - fieldTag, - sleepStage: stage, - // Record-scoped so a nap's segments never collide with the main - // sleep's and a re-fetch overwrites in place. - externalId: `sleep:${s.id}:${fieldTag}`, - }); - } - i += run; - } - return out.length > 0 ? out : null; -} - -/** - * Oura sleep periods that are not sleep. Oura's `type` marks a detected period - * the person turned down in the app's confirm prompt as `rest` ("falsely - * detected sleep / nap, rejected by the user") and one they deleted as - * `deleted`; the sleep collection still returns both. Importing them made an - * afternoon on the sofa a second sleep session on that day, which added its - * minutes to the night and pulled the night's midpoint into the afternoon. - * `sleep`, `long_sleep` and `late_nap` are periods Oura counts, and an unknown - * or missing type stays in, as before. - */ -const RETIRED_OURA_SLEEP_TYPES: ReadonlySet = new Set([ - "rest", - "deleted", -]); - -/** True for an Oura sleep period the person rejected or deleted. */ -export function isRetiredOuraSleepPeriod(s: Pick): boolean { - return typeof s.type === "string" && RETIRED_OURA_SLEEP_TYPES.has(s.type); -} - -/** - * Map one Oura sleep record. A rejected or deleted period - * (`isRetiredOuraSleepPeriod`) maps to nothing. When the real `sleep_phase_5_min` hypnogram is - * present we emit timed per-segment `SLEEP_DURATION` rows (Oura's measured - * timeline); otherwise we fall back to the four per-stage totals stamped on the - * sleep END. Either way we add efficiency, HRV, RHR (`lowest_heart_rate`), and - * respiratory rate. - */ -export function mapSleep(s: OuraSleep, tz?: string): MappedMeasurement[] { - if (isRetiredOuraSleepPeriod(s)) return []; - const measuredAt = dayAnchor(s.day, tz, s.bedtime_end); - if (!measuredAt) return []; - const out: MappedMeasurement[] = []; - - const timeline = mapSleepTimeline(s); - if (timeline) { - out.push(...timeline); - } else { - // Fallback: no hypnogram — stamp the four stage totals on the sleep END. - const stages: Array< - [number | null | undefined, MappedMeasurement["sleepStage"], string] - > = [ - [s.light_sleep_duration, "CORE", "sleep_core"], - [s.deep_sleep_duration, "DEEP", "sleep_deep"], - [s.rem_sleep_duration, "REM", "sleep_rem"], - [s.awake_time, "AWAKE", "sleep_awake"], - ]; - for (const [sec, stage, fieldTag] of stages) { - if (typeof sec === "number" && sec >= 0) { - out.push({ - type: "SLEEP_DURATION", - value: importValue(sec * SEC_TO_MIN), - unit: "minutes", - measuredAt, - fieldTag, - sleepStage: stage, - // Record-scoped so a nap and the main sleep on the same day don't - // collapse onto one key (B2) — the legacy day-keyed key did. - externalId: `sleep:${s.id}:${fieldTag}`, - }); - } - } - } - // The nightly scalars are record-scoped too (B2): a nap and the main sleep on - // the same day would otherwise overwrite each other's efficiency / HRV / RHR / - // respiratory rate on a shared day key. - if (typeof s.efficiency === "number") { - out.push({ - type: "SLEEP_EFFICIENCY", - value: importValue(s.efficiency), - unit: "%", - measuredAt, - fieldTag: "sleep_eff", - externalId: `sleep:${s.id}:sleep_eff`, - }); - } - if (typeof s.average_hrv === "number" && s.average_hrv > 0) { - out.push({ - type: "HRV_RMSSD", - value: importValue(s.average_hrv), - unit: "ms", - measuredAt, - fieldTag: "hrv_rmssd", - externalId: `sleep:${s.id}:hrv_rmssd`, - }); - } - if (typeof s.lowest_heart_rate === "number" && s.lowest_heart_rate > 0) { - out.push({ - type: "RESTING_HEART_RATE", - value: Math.round(s.lowest_heart_rate), - unit: "bpm", - measuredAt, - fieldTag: "rhr", - externalId: `sleep:${s.id}:rhr`, - }); - } - if (typeof s.average_breath === "number" && s.average_breath > 0) { - out.push({ - type: "RESPIRATORY_RATE", - value: importValue(s.average_breath), - unit: "breaths/min", - measuredAt, - fieldTag: "resp_rate", - externalId: `sleep:${s.id}:resp_rate`, - }); - } - return out; -} - -/** Map one Oura daily-sleep record: the headline Sleep Score → `SLEEP_SCORE`. */ -export function mapDailySleep( - d: OuraDailySleep, - tz?: string, -): MappedMeasurement[] { - if (typeof d.score !== "number") return []; - const measuredAt = dayAnchor(d.day, tz, d.timestamp); - if (!measuredAt) return []; - return [ - { - type: "SLEEP_SCORE", - value: importValue(d.score), - unit: "score", - measuredAt, - fieldTag: "sleep_score", - }, - ]; -} - -/** Map one Oura daily-spo2 record: average overnight SpO2 → `OXYGEN_SATURATION`. */ -export function mapDailySpo2( - s: OuraDailySpo2, - tz?: string, -): MappedMeasurement[] { - const avg = s.spo2_percentage?.average; - if (typeof avg !== "number" || avg <= 0) return []; - // Anchor SpO2 at the day's local noon (no per-record instant in the - // daily_spo2 collection); the recovery / rollup readers key off the local day. - const measuredAt = dayAnchor(s.day, tz); - if (!measuredAt) return []; - return [ - { - type: "OXYGEN_SATURATION", - value: importValue(avg), - unit: "%", - measuredAt, - fieldTag: "spo2", - }, - ]; -} - -/** - * Map one Oura daily-activity record: steps and active energy. - * - * `equivalent_walking_distance` is deliberately not mapped. Oura derives it - * from the day's whole activity (the distance a walk of the same effort would - * have covered), so an hour of cycling or yoga adds kilometres nobody walked. - * Stored as `WALKING_RUNNING_DISTANCE` it read as real distance, and on a day - * no other source covered it became the day's distance. The rows earlier - * syncs wrote under `activity::distance` are retired on their own - * (`./retire-distance`). - */ -export function mapDailyActivity( - a: OuraDailyActivity, - tz?: string, -): MappedMeasurement[] { - const measuredAt = dayAnchor(a.day, tz, a.timestamp); - if (!measuredAt) return []; - const out: MappedMeasurement[] = []; - if (typeof a.steps === "number" && a.steps >= 0) { - out.push({ - type: "ACTIVITY_STEPS", - value: Math.round(a.steps), - unit: "steps", - measuredAt, - fieldTag: "steps", - }); - } - if (typeof a.active_calories === "number" && a.active_calories >= 0) { - out.push({ - type: "ACTIVE_ENERGY_BURNED", - value: importValue(a.active_calories), - unit: "kcal", - measuredAt, - fieldTag: "active_energy", - }); - } - return out; -} - -/** - * Map one Oura `vO2_max` record: the dedicated cardio-fitness collection → - * `VO2_MAX` (mL/(kg·min), the canonical DB unit — no conversion). Skips a - * record with no positive value. - */ -export function mapVo2Max(v: OuraVo2Max, tz?: string): MappedMeasurement[] { - if (typeof v.vo2_max !== "number" || v.vo2_max <= 0) return []; - const measuredAt = dayAnchor(v.day, tz, v.timestamp); - if (!measuredAt) return []; - return [ - { - type: "VO2_MAX", - value: importValue(v.vo2_max), - unit: "mL/(kg·min)", - measuredAt, - fieldTag: "vo2_max", - }, - ]; -} - -/** - * Map one Oura `daily_cardiovascular_age` record → `VASCULAR_AGE` (years, the - * canonical DB unit — no conversion). Skips a record with no positive value. - * Shares the `VASCULAR_AGE` bucket with Withings' Body Scan arterial-age, kept - * distinct from the two vendors only by `source`. - */ -export function mapCardiovascularAge( - c: OuraCardiovascularAge, - tz?: string, -): MappedMeasurement[] { - if (typeof c.vascular_age !== "number" || c.vascular_age <= 0) return []; - const measuredAt = dayAnchor(c.day, tz); - if (!measuredAt) return []; - return [ - { - type: "VASCULAR_AGE", - value: importValue(c.vascular_age), - unit: "years", - measuredAt, - fieldTag: "vascular_age", - }, - ]; -} - -/** - * Single source of truth for the Oura resilience level → ordinal encoding. - * Oura's `daily_resilience.level` is a categorical band; we store it ORDINAL- - * ENCODED in the numeric Measurement `value` so it fits the existing model with - * no new categorical column. Keep in lock-step with the schema comment on the - * `RESILIENCE` enum value and migration 0186. - */ -export const RESILIENCE_LEVELS: Record = { - limited: 1, - adequate: 2, - solid: 3, - strong: 4, - exceptional: 5, -}; - -/** The unit recorded for a RESILIENCE row — the ordinal level scale (1–5). */ -export const RESILIENCE_UNIT = "level" as const; - -/** - * Map one Oura `daily_resilience` record → `RESILIENCE`, ordinal-encoded - * (limited=1 … exceptional=5) into the numeric `value`. An unknown / missing - * level string mints NO row (skipped, never coerced to 0) so a future Oura band - * we do not recognise never lands as a misleading reading. Anchored at the - * day's local noon (the collection carries no per-record instant). - */ -export function mapResilience( - r: OuraResilience, - tz?: string, -): MappedMeasurement[] { - const level = typeof r.level === "string" ? r.level.toLowerCase() : null; - if (!level) return []; - const ordinal = RESILIENCE_LEVELS[level]; - if (typeof ordinal !== "number") return []; - const measuredAt = dayAnchor(r.day, tz); - if (!measuredAt) return []; - return [ - { - type: "RESILIENCE", - value: ordinal, - unit: RESILIENCE_UNIT, - measuredAt, - fieldTag: "resilience", - }, - ]; -} - -/** Heart-rate readings outside this band are not a ring's reading. */ -const OURA_BPM_MIN = 20; -const OURA_BPM_MAX = 250; - -/** - * Map one `heartrate` sample → `PULSE` (bpm), the intraday heart-rate type - * Google Health and Fitbit write their minute stream to. Keyed on the - * sample's own instant (`heartrate:`): Oura emits one reading per - * instant, and a sample is final once it is in the cloud, so a re-read lands - * on the same key. A sample without a parseable instant or with a bpm - * outside a human band maps to nothing. - */ -export function mapHeartRateSample( - sample: OuraHeartRateSample, -): MappedMeasurement | null { - const bpm = sample.bpm; - if (typeof bpm !== "number" || !Number.isFinite(bpm)) return null; - if (bpm < OURA_BPM_MIN || bpm > OURA_BPM_MAX) return null; - if (typeof sample.timestamp !== "string") return null; - const at = new Date(sample.timestamp); - if (Number.isNaN(at.getTime())) return null; - const iso = at.toISOString(); - return { - type: "PULSE", - value: Math.round(bpm), - unit: "bpm", - measuredAt: at, - fieldTag: "hr", - externalId: `heartrate:${iso}`, - }; -} - -// ─── Cycle-phase → CycleDayLog mapping (v1.29.x) ─────────────────── -// -// `daily_cycle_phases` does NOT feed `Measurement` like every collection -// above — it feeds the cycle tracker's `CycleDayLog` via -// `syncUserOuraCyclePhases` in `./cycle-sync`, which owns the write-side -// "manual always wins" guard. This module only derives WHICH days look like -// a period day; it never touches the database. - -const OURA_MENSTRUAL_PHASE = "menstrual"; -const OURA_FOLLICULAR_PHASE = "follicular"; -const OURA_LUTEAL_PHASE = "luteal"; - -/** - * Derive the (sparse) set of `YYYY-MM-DD` days that look like a period day - * from a `daily_cycle_phases` series. The endpoint reports only the PHASE, - * not menstrual flow directly, so a period day is inferred two ways: - * - * 1. A record whose `phase` is literally `"menstrual"` — some algorithm - * versions / accounts may emit this directly. - * 2. The single day a `"luteal"` phase is immediately (calendar-adjacent) - * followed by a `"follicular"` phase. Oura's own definition of the - * follicular phase is "from the first day of menstruation to - * ovulation", so this transition day is the best period-start signal - * the two-phase (follicular/luteal) shape carries. - * - * Records are sorted by `day` internally (the collection's page order is not - * guaranteed to be chronological). Returns bare day strings — the caller - * writes each as a single-day conservative `flow: LIGHT` marker (the same - * "logged but unspecified intensity" boundary the HealthKit `unspecified` - * flow mapping uses), never a multi-day bleed span this endpoint gives no - * evidence for. - */ -export function derivePeriodDaysFromCyclePhases( - records: readonly OuraCyclePhase[], -): string[] { - const sorted = [...records] - .filter((r) => typeof r.day === "string" && r.day.length > 0) - .sort((a, b) => dayDiff(a.day, b.day)); - - const days = new Set(); - let prevPhase: string | null = null; - let prevDay: string | null = null; - - for (const r of sorted) { - const phase = typeof r.phase === "string" ? r.phase.toLowerCase() : null; - - if (phase === OURA_MENSTRUAL_PHASE) { - days.add(r.day); - } else if ( - phase === OURA_FOLLICULAR_PHASE && - prevPhase === OURA_LUTEAL_PHASE && - prevDay !== null && - dayDiff(r.day, prevDay) === 1 - ) { - days.add(r.day); - } - - if (phase) { - prevPhase = phase; - prevDay = r.day; - } - } - - return Array.from(days); -} diff --git a/src/lib/oura/credentials.ts b/src/lib/oura/credentials.ts index 95b06898e..1c46b33bf 100644 --- a/src/lib/oura/credentials.ts +++ b/src/lib/oura/credentials.ts @@ -13,7 +13,7 @@ import { prisma } from "@/lib/db"; import { Prisma } from "@/generated/prisma/client"; import { decrypt, encrypt } from "@/lib/crypto"; -import { getOuraCredentials, type OuraCredentials } from "./client"; +import { getOuraCredentials, type OuraCredentials } from "./oauth"; /** * Resolve the user's Oura OAuth client id/secret, DB-first then env. diff --git a/src/lib/oura/cycle-sync.ts b/src/lib/oura/cycle-sync.ts index bb6d0a5dd..5a8db8876 100644 --- a/src/lib/oura/cycle-sync.ts +++ b/src/lib/oura/cycle-sync.ts @@ -35,10 +35,8 @@ import { prisma } from "@/lib/db"; import { getEvent } from "@/lib/logging/context"; import { isCycleAvailableForUser } from "@/lib/cycle/gate"; -import { - fetchDailyCyclePhases, - derivePeriodDaysFromCyclePhases, -} from "./client"; +import { fetchDailyCyclePhases } from "./client"; +import { derivePeriodDaysFromCyclePhases } from "./map"; /** The conservative "logged but unspecified intensity" flow level — mirrors * the HealthKit `unspecified` → `LIGHT` boundary documented in diff --git a/src/lib/oura/map.ts b/src/lib/oura/map.ts new file mode 100644 index 000000000..d0e5525b8 --- /dev/null +++ b/src/lib/oura/map.ts @@ -0,0 +1,556 @@ +/** + * Oura records to measurement rows: pure functions over the wire shapes + * (`./wire-types.ts`), with no I/O. `__tests__/map-fixtures.test.ts` pins what + * each makes of sanitised fixtures. + */ +import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz"; +import { dayDiff } from "@/lib/cycle/day-math"; +import { importValue } from "@/lib/integrations/import-value"; +import type { + OuraSleep, + OuraReadiness, + OuraDailySleep, + OuraDailySpo2, + OuraVo2Max, + OuraCardiovascularAge, + OuraResilience, + OuraCyclePhase, + OuraDailyActivity, + OuraHeartRateSample, +} from "./wire-types"; + +const SEC_TO_MIN = 1 / 60; + +export interface MappedMeasurement { + type: string; + value: number; + unit: string; + measuredAt: Date; + fieldTag: string; + sleepStage?: "CORE" | "DEEP" | "REM" | "AWAKE"; + /** + * Per-row externalId override. Most rows derive their externalId from the + * resource prefix + day + fieldTag in the sync layer, but the per-segment + * sleep-timeline rows (and any other row that needs a record-scoped key) + * carry their own here so several rows of one night stay distinct under + * `userId_type_source_externalId`. The sync layer uses `m.externalId ??` + * the default key. + */ + externalId?: string; +} + +function dayAnchor( + day: string, + tz: string | undefined, + bedtimeEnd?: string, +): Date | null { + // Prefer the precise wake instant when present; else anchor the date-only + // day at local noon in the user's zone (canonicalDailyTimestamp), which + // reads the same calendar day there. Noon UTC, the previous anchor, is + // already the next day from UTC+13 on. + if (bedtimeEnd) { + const d = new Date(bedtimeEnd); + return Number.isNaN(d.getTime()) ? null : d; + } + const d = canonicalDailyTimestamp(day, tz); + return Number.isNaN(d.getTime()) ? null : d; +} + +/** + * Map one Oura readiness record: + * - `score` → `RECOVERY_SCORE` (source OURA, feeding the recovery ladder + * below WHOOP). + * - `temperature_deviation` → `BODY_TEMPERATURE_DEVIATION` (signed °C offset + * from the personal baseline — illness / luteal-phase / stress signal). + * + * The eight readiness `contributors` are the *why* behind the score; they are + * carried back to the caller separately (not minted as Measurement rows here + * since each is a sub-score, not a stored metric) — see the dropped-field note. + * `temperature_trend_deviation` is the long-term trend twin; we keep only the + * nightly deviation as the actionable signal. + */ +export function mapReadiness( + r: OuraReadiness, + tz?: string, +): MappedMeasurement[] { + const measuredAt = dayAnchor(r.day, tz, r.timestamp); + if (!measuredAt) return []; + const out: MappedMeasurement[] = []; + if (typeof r.score === "number") { + out.push({ + type: "RECOVERY_SCORE", + value: importValue(r.score), + unit: "score", + measuredAt, + fieldTag: "recovery", + }); + } + if (typeof r.temperature_deviation === "number") { + out.push({ + type: "BODY_TEMPERATURE_DEVIATION", + value: importValue(r.temperature_deviation), + unit: "celsius", + measuredAt, + fieldTag: "temp_deviation", + }); + } + return out; +} + +/** + * Oura 5-minute hypnogram digit → our `SleepStage`. Oura encodes one digit per + * 5-min interval from `bedtime_start`: `1`=deep, `2`=light, `3`=rem, `4`=awake. + * Light maps to our CORE stage (the granular-asleep stage the sleep-night + * reader scores), matching the `light_sleep_duration → CORE` convention the + * stage-totals path already uses. Re-verify against `cloud.ouraring.com/v2/docs`. + */ +const HYPNOGRAM_STAGE: Record< + string, + NonNullable +> = { + "1": "DEEP", + "2": "CORE", + "3": "REM", + "4": "AWAKE", +}; + +/** Each hypnogram digit covers 5 minutes. */ +const HYPNOGRAM_INTERVAL_MIN = 5; + +const HYPNOGRAM_INTERVAL_MS = HYPNOGRAM_INTERVAL_MIN * 60_000; + +/** + * Build timed per-segment `SLEEP_DURATION` rows from Oura's real measured + * `sleep_phase_5_min` hypnogram. Consecutive identical digits collapse into one + * segment; each segment is stamped at its own END instant (start + duration) + * relative to `bedtime_start`, so the sleep-night reader (which derives + * `start = end − value·60_000`) reconstructs the actual clock-time timeline — + * NOT the degraded all-on-`bedtime_end` shape. The rows are `reconstructed: + * false` by virtue of OURA being absent from the reader's + * `RECONSTRUCTED_TIMELINE_SOURCES` set (WHOOP only). Returns `null` when the + * hypnogram is absent / unusable so the caller falls back to stage totals. + */ +function mapSleepTimeline(s: OuraSleep): MappedMeasurement[] | null { + const hyp = s.sleep_phase_5_min; + if (typeof hyp !== "string" || hyp.length === 0 || !s.bedtime_start) { + return null; + } + const onset = new Date(s.bedtime_start); + if (Number.isNaN(onset.getTime())) return null; + + const out: MappedMeasurement[] = []; + let i = 0; + while (i < hyp.length) { + const digit = hyp[i]; + const stage = HYPNOGRAM_STAGE[digit]; + // Walk the run of identical digits. + let run = 1; + while (i + run < hyp.length && hyp[i + run] === digit) run += 1; + if (stage) { + const segStartMs = onset.getTime() + i * HYPNOGRAM_INTERVAL_MS; + const segEndMs = segStartMs + run * HYPNOGRAM_INTERVAL_MS; + // Keyed by the run's own START instant, NOT a positional run index. A + // revised hypnogram re-segments the night (runs merge/split), so an + // index shifted every following run onto the wrong row — wrong + // boundaries updated in place plus phantom tail rows the night-total + // double-counted. A run's start instant is a stable coordinate: an + // unchanged run keeps its id (re-classification overwrites in place), + // and whatever the revision orphaned is swept by the record-scoped + // cleanup in the sync (which also clears legacy `seg:` rows). + const fieldTag = `seg:${new Date(segStartMs).toISOString()}`; + out.push({ + type: "SLEEP_DURATION", + value: importValue(run * HYPNOGRAM_INTERVAL_MIN), + unit: "minutes", + measuredAt: new Date(segEndMs), + fieldTag, + sleepStage: stage, + // Record-scoped so a nap's segments never collide with the main + // sleep's and a re-fetch overwrites in place. + externalId: `sleep:${s.id}:${fieldTag}`, + }); + } + i += run; + } + return out.length > 0 ? out : null; +} + +/** + * Oura sleep periods that are not sleep. Oura's `type` marks a detected period + * the person turned down in the app's confirm prompt as `rest` ("falsely + * detected sleep / nap, rejected by the user") and one they deleted as + * `deleted`; the sleep collection still returns both. Importing them made an + * afternoon on the sofa a second sleep session on that day, which added its + * minutes to the night and pulled the night's midpoint into the afternoon. + * `sleep`, `long_sleep` and `late_nap` are periods Oura counts, and an unknown + * or missing type stays in, as before. + */ +const RETIRED_OURA_SLEEP_TYPES: ReadonlySet = new Set([ + "rest", + "deleted", +]); + +/** True for an Oura sleep period the person rejected or deleted. */ +export function isRetiredOuraSleepPeriod(s: Pick): boolean { + return typeof s.type === "string" && RETIRED_OURA_SLEEP_TYPES.has(s.type); +} + +/** + * Map one Oura sleep record. A rejected or deleted period + * (`isRetiredOuraSleepPeriod`) maps to nothing. When the real `sleep_phase_5_min` hypnogram is + * present we emit timed per-segment `SLEEP_DURATION` rows (Oura's measured + * timeline); otherwise we fall back to the four per-stage totals stamped on the + * sleep END. Either way we add efficiency, HRV, RHR (`lowest_heart_rate`), and + * respiratory rate. + */ +export function mapSleep(s: OuraSleep, tz?: string): MappedMeasurement[] { + if (isRetiredOuraSleepPeriod(s)) return []; + const measuredAt = dayAnchor(s.day, tz, s.bedtime_end); + if (!measuredAt) return []; + const out: MappedMeasurement[] = []; + + const timeline = mapSleepTimeline(s); + if (timeline) { + out.push(...timeline); + } else { + // Fallback: no hypnogram — stamp the four stage totals on the sleep END. + const stages: Array< + [number | null | undefined, MappedMeasurement["sleepStage"], string] + > = [ + [s.light_sleep_duration, "CORE", "sleep_core"], + [s.deep_sleep_duration, "DEEP", "sleep_deep"], + [s.rem_sleep_duration, "REM", "sleep_rem"], + [s.awake_time, "AWAKE", "sleep_awake"], + ]; + for (const [sec, stage, fieldTag] of stages) { + if (typeof sec === "number" && sec >= 0) { + out.push({ + type: "SLEEP_DURATION", + value: importValue(sec * SEC_TO_MIN), + unit: "minutes", + measuredAt, + fieldTag, + sleepStage: stage, + // Record-scoped so a nap and the main sleep on the same day don't + // collapse onto one key (B2) — the legacy day-keyed key did. + externalId: `sleep:${s.id}:${fieldTag}`, + }); + } + } + } + // The nightly scalars are record-scoped too (B2): a nap and the main sleep on + // the same day would otherwise overwrite each other's efficiency / HRV / RHR / + // respiratory rate on a shared day key. + if (typeof s.efficiency === "number") { + out.push({ + type: "SLEEP_EFFICIENCY", + value: importValue(s.efficiency), + unit: "%", + measuredAt, + fieldTag: "sleep_eff", + externalId: `sleep:${s.id}:sleep_eff`, + }); + } + if (typeof s.average_hrv === "number" && s.average_hrv > 0) { + out.push({ + type: "HRV_RMSSD", + value: importValue(s.average_hrv), + unit: "ms", + measuredAt, + fieldTag: "hrv_rmssd", + externalId: `sleep:${s.id}:hrv_rmssd`, + }); + } + if (typeof s.lowest_heart_rate === "number" && s.lowest_heart_rate > 0) { + out.push({ + type: "RESTING_HEART_RATE", + value: Math.round(s.lowest_heart_rate), + unit: "bpm", + measuredAt, + fieldTag: "rhr", + externalId: `sleep:${s.id}:rhr`, + }); + } + if (typeof s.average_breath === "number" && s.average_breath > 0) { + out.push({ + type: "RESPIRATORY_RATE", + value: importValue(s.average_breath), + unit: "breaths/min", + measuredAt, + fieldTag: "resp_rate", + externalId: `sleep:${s.id}:resp_rate`, + }); + } + return out; +} + +/** Map one Oura daily-sleep record: the headline Sleep Score → `SLEEP_SCORE`. */ +export function mapDailySleep( + d: OuraDailySleep, + tz?: string, +): MappedMeasurement[] { + if (typeof d.score !== "number") return []; + const measuredAt = dayAnchor(d.day, tz, d.timestamp); + if (!measuredAt) return []; + return [ + { + type: "SLEEP_SCORE", + value: importValue(d.score), + unit: "score", + measuredAt, + fieldTag: "sleep_score", + }, + ]; +} + +/** Map one Oura daily-spo2 record: average overnight SpO2 → `OXYGEN_SATURATION`. */ +export function mapDailySpo2( + s: OuraDailySpo2, + tz?: string, +): MappedMeasurement[] { + const avg = s.spo2_percentage?.average; + if (typeof avg !== "number" || avg <= 0) return []; + // Anchor SpO2 at the day's local noon (no per-record instant in the + // daily_spo2 collection); the recovery / rollup readers key off the local day. + const measuredAt = dayAnchor(s.day, tz); + if (!measuredAt) return []; + return [ + { + type: "OXYGEN_SATURATION", + value: importValue(avg), + unit: "%", + measuredAt, + fieldTag: "spo2", + }, + ]; +} + +/** + * Map one Oura daily-activity record: steps and active energy. + * + * `equivalent_walking_distance` is deliberately not mapped. Oura derives it + * from the day's whole activity (the distance a walk of the same effort would + * have covered), so an hour of cycling or yoga adds kilometres nobody walked. + * Stored as `WALKING_RUNNING_DISTANCE` it read as real distance, and on a day + * no other source covered it became the day's distance. The rows earlier + * syncs wrote under `activity::distance` are retired on their own + * (`./retire-distance`). + */ +export function mapDailyActivity( + a: OuraDailyActivity, + tz?: string, +): MappedMeasurement[] { + const measuredAt = dayAnchor(a.day, tz, a.timestamp); + if (!measuredAt) return []; + const out: MappedMeasurement[] = []; + if (typeof a.steps === "number" && a.steps >= 0) { + out.push({ + type: "ACTIVITY_STEPS", + value: Math.round(a.steps), + unit: "steps", + measuredAt, + fieldTag: "steps", + }); + } + if (typeof a.active_calories === "number" && a.active_calories >= 0) { + out.push({ + type: "ACTIVE_ENERGY_BURNED", + value: importValue(a.active_calories), + unit: "kcal", + measuredAt, + fieldTag: "active_energy", + }); + } + return out; +} + +/** + * Map one Oura `vO2_max` record: the dedicated cardio-fitness collection → + * `VO2_MAX` (mL/(kg·min), the canonical DB unit — no conversion). Skips a + * record with no positive value. + */ +export function mapVo2Max(v: OuraVo2Max, tz?: string): MappedMeasurement[] { + if (typeof v.vo2_max !== "number" || v.vo2_max <= 0) return []; + const measuredAt = dayAnchor(v.day, tz, v.timestamp); + if (!measuredAt) return []; + return [ + { + type: "VO2_MAX", + value: importValue(v.vo2_max), + unit: "mL/(kg·min)", + measuredAt, + fieldTag: "vo2_max", + }, + ]; +} + +/** + * Map one Oura `daily_cardiovascular_age` record → `VASCULAR_AGE` (years, the + * canonical DB unit — no conversion). Skips a record with no positive value. + * Shares the `VASCULAR_AGE` bucket with Withings' Body Scan arterial-age, kept + * distinct from the two vendors only by `source`. + */ +export function mapCardiovascularAge( + c: OuraCardiovascularAge, + tz?: string, +): MappedMeasurement[] { + if (typeof c.vascular_age !== "number" || c.vascular_age <= 0) return []; + const measuredAt = dayAnchor(c.day, tz); + if (!measuredAt) return []; + return [ + { + type: "VASCULAR_AGE", + value: importValue(c.vascular_age), + unit: "years", + measuredAt, + fieldTag: "vascular_age", + }, + ]; +} + +/** + * Single source of truth for the Oura resilience level → ordinal encoding. + * Oura's `daily_resilience.level` is a categorical band; we store it ORDINAL- + * ENCODED in the numeric Measurement `value` so it fits the existing model with + * no new categorical column. Keep in lock-step with the schema comment on the + * `RESILIENCE` enum value and migration 0186. + */ +export const RESILIENCE_LEVELS: Record = { + limited: 1, + adequate: 2, + solid: 3, + strong: 4, + exceptional: 5, +}; + +/** The unit recorded for a RESILIENCE row — the ordinal level scale (1–5). */ +export const RESILIENCE_UNIT = "level" as const; + +/** + * Map one Oura `daily_resilience` record → `RESILIENCE`, ordinal-encoded + * (limited=1 … exceptional=5) into the numeric `value`. An unknown / missing + * level string mints NO row (skipped, never coerced to 0) so a future Oura band + * we do not recognise never lands as a misleading reading. Anchored at the + * day's local noon (the collection carries no per-record instant). + */ +export function mapResilience( + r: OuraResilience, + tz?: string, +): MappedMeasurement[] { + const level = typeof r.level === "string" ? r.level.toLowerCase() : null; + if (!level) return []; + const ordinal = RESILIENCE_LEVELS[level]; + if (typeof ordinal !== "number") return []; + const measuredAt = dayAnchor(r.day, tz); + if (!measuredAt) return []; + return [ + { + type: "RESILIENCE", + value: ordinal, + unit: RESILIENCE_UNIT, + measuredAt, + fieldTag: "resilience", + }, + ]; +} + +/** Heart-rate readings outside this band are not a ring's reading. */ +const OURA_BPM_MIN = 20; + +const OURA_BPM_MAX = 250; + +/** + * Map one `heartrate` sample → `PULSE` (bpm), the intraday heart-rate type + * Google Health and Fitbit write their minute stream to. Keyed on the + * sample's own instant (`heartrate:`): Oura emits one reading per + * instant, and a sample is final once it is in the cloud, so a re-read lands + * on the same key. A sample without a parseable instant or with a bpm + * outside a human band maps to nothing. + */ +export function mapHeartRateSample( + sample: OuraHeartRateSample, +): MappedMeasurement | null { + const bpm = sample.bpm; + if (typeof bpm !== "number" || !Number.isFinite(bpm)) return null; + if (bpm < OURA_BPM_MIN || bpm > OURA_BPM_MAX) return null; + if (typeof sample.timestamp !== "string") return null; + const at = new Date(sample.timestamp); + if (Number.isNaN(at.getTime())) return null; + const iso = at.toISOString(); + return { + type: "PULSE", + value: Math.round(bpm), + unit: "bpm", + measuredAt: at, + fieldTag: "hr", + externalId: `heartrate:${iso}`, + }; +} + +// ─── Cycle-phase → CycleDayLog mapping (v1.29.x) ─────────────────── +// +// `daily_cycle_phases` does NOT feed `Measurement` like every collection +// above — it feeds the cycle tracker's `CycleDayLog` via +// `syncUserOuraCyclePhases` in `./cycle-sync`, which owns the write-side +// "manual always wins" guard. This module only derives WHICH days look like +// a period day; it never touches the database. + +const OURA_MENSTRUAL_PHASE = "menstrual"; + +const OURA_FOLLICULAR_PHASE = "follicular"; + +const OURA_LUTEAL_PHASE = "luteal"; + +/** + * Derive the (sparse) set of `YYYY-MM-DD` days that look like a period day + * from a `daily_cycle_phases` series. The endpoint reports only the PHASE, + * not menstrual flow directly, so a period day is inferred two ways: + * + * 1. A record whose `phase` is literally `"menstrual"` — some algorithm + * versions / accounts may emit this directly. + * 2. The single day a `"luteal"` phase is immediately (calendar-adjacent) + * followed by a `"follicular"` phase. Oura's own definition of the + * follicular phase is "from the first day of menstruation to + * ovulation", so this transition day is the best period-start signal + * the two-phase (follicular/luteal) shape carries. + * + * Records are sorted by `day` internally (the collection's page order is not + * guaranteed to be chronological). Returns bare day strings — the caller + * writes each as a single-day conservative `flow: LIGHT` marker (the same + * "logged but unspecified intensity" boundary the HealthKit `unspecified` + * flow mapping uses), never a multi-day bleed span this endpoint gives no + * evidence for. + */ +export function derivePeriodDaysFromCyclePhases( + records: readonly OuraCyclePhase[], +): string[] { + const sorted = [...records] + .filter((r) => typeof r.day === "string" && r.day.length > 0) + .sort((a, b) => dayDiff(a.day, b.day)); + + const days = new Set(); + let prevPhase: string | null = null; + let prevDay: string | null = null; + + for (const r of sorted) { + const phase = typeof r.phase === "string" ? r.phase.toLowerCase() : null; + + if (phase === OURA_MENSTRUAL_PHASE) { + days.add(r.day); + } else if ( + phase === OURA_FOLLICULAR_PHASE && + prevPhase === OURA_LUTEAL_PHASE && + prevDay !== null && + dayDiff(r.day, prevDay) === 1 + ) { + days.add(r.day); + } + + if (phase) { + prevPhase = phase; + prevDay = r.day; + } + } + + return Array.from(days); +} diff --git a/src/lib/oura/oauth-descriptor.ts b/src/lib/oura/oauth-descriptor.ts index cfc43a51b..933c9cab9 100644 --- a/src/lib/oura/oauth-descriptor.ts +++ b/src/lib/oura/oauth-descriptor.ts @@ -9,7 +9,7 @@ import { obtainAccessToken, type OAuthTokenDescriptor, } from "@/lib/integrations/oauth-kit/token"; -import { refreshAccessToken, type OuraCredentials } from "./client"; +import { type OuraCredentials, ouraOAuth } from "./oauth"; import { getOuraClientCredentials } from "./credentials"; export const ouraTokenDescriptor: OAuthTokenDescriptor = { @@ -45,7 +45,7 @@ export const ouraTokenDescriptor: OAuthTokenDescriptor = { }, loadCredentials: (userId) => getOuraClientCredentials(userId), refresh: (refreshToken, credentials) => - refreshAccessToken(refreshToken, credentials), + ouraOAuth.refresh(refreshToken, credentials), }; /** diff --git a/src/lib/oura/oauth.ts b/src/lib/oura/oauth.ts new file mode 100644 index 000000000..a1a8151a8 --- /dev/null +++ b/src/lib/oura/oauth.ts @@ -0,0 +1,137 @@ +/** + * The Oura OAuth client: consent URL, code exchange and refresh, over the + * shared factory (`integrations/oauth-kit/client.ts`). + * + * Standard authorization code with refresh tokens; Oura rotates the refresh + * token on every use, and the sync refreshes after a refused read. The client + * id and secret ride in the form (client_secret_post). + * + * Two token endpoints. Apps created in the current developer portal get their + * codes from the authorization server on moi.ouraring.com (the callback + * carries `iss=https://moi.ouraring.com/oauth/v2/ext/oauth-anonymous`), and the + * documented `api.ouraring.com/oauth/token` answers those codes with + * `400 invalid_request`. The live endpoint is always asked first; the legacy + * one only after the live one refused with 400, 401 or 404, which may mean + * "unknown here" for an older app. A code from the current server goes to the + * live endpoint only: an authorization code works once (RFC 6749 4.1.2), so a + * second attempt elsewhere could never succeed. A refused refresh does not + * consume the token, so its retry is safe. Server errors and rate limits are + * never retried elsewhere: they say nothing about the grant. + */ +import { + defineOAuthClient, + type OAuthClientCredentials, +} from "@/lib/integrations/oauth-kit/client"; +import { derivedRedirectUri } from "@/lib/integrations/oauth-kit/redirect-uri"; + +import { OuraApiError } from "./response-classifier"; +import { OURA_OAUTH_SCOPE } from "./scope"; + +export type OuraCredentials = OAuthClientCredentials; + +/** Oura's live token endpoint. */ +export const OURA_OAUTH_TOKEN_URL = + "https://moi.ouraring.com/oauth/v2/ext/oauth-token"; + +/** The documented endpoint, kept only as the fallback for older apps. */ +export const OURA_OAUTH_LEGACY_TOKEN_URL = + "https://api.ouraring.com/oauth/token"; + +/** The origin of Oura's current authorization server. */ +const OURA_MOI_ORIGIN = "https://moi.ouraring.com"; + +/** + * Authorization-server origins an `iss` callback parameter may name + * (RFC 9207). Anything else is a mix-up and refused. + */ +const OURA_ISSUER_ORIGINS = new Set([ + OURA_MOI_ORIGIN, + "https://cloud.ouraring.com", + "https://api.ouraring.com", +]); + +/** + * Check the `iss` the authorization server sent with the callback. Absent is + * fine (older apps never send one); present, it must name one of Oura's own + * authorization servers. + */ +export function isAcceptedOuraIssuer(iss: string | null): boolean { + if (iss === null) return true; + try { + const url = new URL(iss); + return url.protocol === "https:" && OURA_ISSUER_ORIGINS.has(url.origin); + } catch { + return false; + } +} + +/** True when the code was issued by the current authorization server. */ +function issuedByMoi(iss: string | null | undefined): boolean { + if (!iss) return false; + try { + return new URL(iss).origin === OURA_MOI_ORIGIN; + } catch { + return false; + } +} + +/** + * Env-configured shared OAuth credentials: the fallback app used when a user + * has not registered their own client id and secret. The per-user resolver + * `getOuraClientCredentials` (database first) is the primary path. + */ +export function getOuraCredentials(): OuraCredentials | null { + const clientId = process.env.OURA_CLIENT_ID; + const clientSecret = process.env.OURA_CLIENT_SECRET; + if (!clientId || !clientSecret) return null; + return { clientId, clientSecret }; +} + +export interface OuraTokenResponse { + access_token: string; + refresh_token: string; + expires_in: number; + token_type?: string; + scope?: string; +} + +export const ouraOAuth = defineOAuthClient({ + service: "oura", + authorizeUrl: "https://cloud.ouraring.com/oauth/authorize", + tokenUrl: OURA_OAUTH_TOKEN_URL, + legacyTokenUrl: { + url: OURA_OAUTH_LEGACY_TOKEN_URL, + retryOn: new Set([400, 401, 404]), + }, + redirectUri: () => + derivedRedirectUri("OURA_REDIRECT_URI", "/api/oura/callback"), + // The scopes come from `./scope`, which maps every collection HealthLog + // reads to the scope Oura enforces for it. A grant from before a scope was + // added keeps the old set until the person reconnects once. + authorizeParams: () => ({ scope: OURA_OAUTH_SCOPE }), + clientAuth: { kind: "form" }, + acceptJson: true, + refresh: true, + error: (failure) => + new OuraApiError({ + verb: failure.verb, + classification: failure.classification, + httpStatus: failure.status, + reason: failure.reason, + upstreamError: failure.upstreamError, + }), +}); + +/** + * Exchange an authorization code. `iss` is the callback's issuer parameter: a + * code from the current authorization server goes to the live endpoint only. + */ +export function exchangeOuraCode( + code: string, + credentials: OuraCredentials, + iss?: string | null, +): Promise { + return ouraOAuth.exchange(code, credentials, { + useLegacyEndpoint: !issuedByMoi(iss), + }); +} diff --git a/src/lib/oura/sync-heart-rate.ts b/src/lib/oura/sync-heart-rate.ts index 48f6d66b3..9c0a9a1f4 100644 --- a/src/lib/oura/sync-heart-rate.ts +++ b/src/lib/oura/sync-heart-rate.ts @@ -21,12 +21,9 @@ import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit import { dropImplausibleMeasurements } from "@/lib/measurements/plausibility-gate"; import { collapseToTypeDayKeys } from "@/lib/rollups/measurement-rollups"; import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; -import { - forEachHeartRatePage, - mapHeartRateSample, - type MappedMeasurement, - type OuraHeartRateSample, -} from "./client"; +import { forEachHeartRatePage } from "./client"; +import { mapHeartRateSample, type MappedMeasurement } from "./map"; +import { type OuraHeartRateSample } from "./wire-types"; /** Rows per `createMany`: bounded whatever size of page Oura sends. */ export const OURA_HEART_RATE_WRITE_CHUNK = 500; diff --git a/src/lib/oura/sync-workouts.ts b/src/lib/oura/sync-workouts.ts index 0d3ff8def..49bfd0eb2 100644 --- a/src/lib/oura/sync-workouts.ts +++ b/src/lib/oura/sync-workouts.ts @@ -22,7 +22,7 @@ import { emitInsertedWorkoutArrival } from "@/lib/arrivals/workout-emit"; import { annotate, getEvent } from "@/lib/logging/context"; import type { Prisma } from "@/generated/prisma/client"; import type { WorkoutSportType } from "@/lib/validations/workout"; -import type { OuraWorkout } from "./client"; +import type { OuraWorkout } from "./wire-types"; import { mapOuraSportType } from "./sport-map"; export interface OuraWorkoutRow { diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index fe4f89984..3f8a901d4 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -45,12 +45,10 @@ import { toFailureKind, type FailureKind, } from "@/lib/integrations/status"; -import { - emitInsertedMeasurementArrivals, - type InsertedMeasurementArrivalRow, -} from "@/lib/arrivals/measurement-emit"; +import type { InsertedMeasurementArrivalRow } from "@/lib/arrivals/measurement-emit"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; +import { afterProviderWrite } from "@/lib/integrations/sync-kit/after-write"; import type { SyncWriteResult } from "@/lib/outcome/written-outcome"; import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; import { @@ -71,6 +69,8 @@ import { fetchSleep, fetchVo2Max, fetchWorkouts, +} from "./client"; +import { isRetiredOuraSleepPeriod, mapCardiovascularAge, mapDailyActivity, @@ -81,7 +81,7 @@ import { mapSleep, mapVo2Max, type MappedMeasurement, -} from "./client"; +} from "./map"; import { getOuraConnection, syncOuraScopeDenied } from "./credentials"; import { refreshOuraAfterRefusal } from "./oauth-descriptor"; import { OuraApiError, classifyOuraError } from "./response-classifier"; @@ -885,17 +885,15 @@ export async function upsertOuraMeasurements( } opts.onInserted?.(insertedRows); - if (opts.emitArrivals !== false) { - void emitInsertedMeasurementArrivals(userId, insertedRows, "oura").catch( - () => {}, - ); - } - // The shared tail: the rollups, the measurement read caches back to the - // oldest reading written (a history backfill writes through here and has - // no other invalidation), and the cached assessments of the types. - await afterMeasurementMutation(userId, touched, { - source: "sync", - label: "oura", + // The shared tail (a history backfill writes through here and has no other + // invalidation); the caller may emit the arrivals itself. + await afterProviderWrite({ + userId, + provider: "oura", + touched, + ...(opts.emitArrivals !== false + ? { arrivals: { rows: insertedRows, source: "oura" as const } } + : {}), }); return imported; diff --git a/src/lib/oura/wire-types.ts b/src/lib/oura/wire-types.ts new file mode 100644 index 000000000..148790571 --- /dev/null +++ b/src/lib/oura/wire-types.ts @@ -0,0 +1,201 @@ +/** + * The Oura Cloud API v2 wire shapes HealthLog reads: the collection + * envelope and one interface per collection record. Shared by the endpoint + * fetchers (`./client.ts`) and the mappers (`./map.ts`). + */ +/** v2 paginated envelope: `{ data, next_token }`. */ +export interface OuraCollection { + data: T[]; + next_token?: string | null; +} + +export interface OuraSleep { + id: string; + day: string; + /** + * `long_sleep` | `sleep` | `late_nap` | `rest` | `deleted`. `rest` is a + * detected period the person rejected and `deleted` one they removed; see + * `isRetiredOuraSleepPeriod`. + */ + type?: string | null; + bedtime_start?: string; + bedtime_end?: string; + total_sleep_duration?: number | null; + time_in_bed?: number | null; + efficiency?: number | null; + rem_sleep_duration?: number | null; + deep_sleep_duration?: number | null; + light_sleep_duration?: number | null; + awake_time?: number | null; + average_heart_rate?: number | null; + lowest_heart_rate?: number | null; + average_hrv?: number | null; + average_breath?: number | null; + /** + * 5-minute hypnogram: one digit per 5-min interval from `bedtime_start`. + * Oura's measured per-stage timeline — `1`=deep, `2`=light, `3`=rem, + * `4`=awake. Re-verify the digit→stage mapping against + * `cloud.ouraring.com/v2/docs` at build time (the encoding is published in + * the `Sleep` model; cross-checked against the `oura-api`/`@pinta365/oura-api` + * client libraries). Unlike WHOOP — which exposes only stage totals and must + * synthesise an order — Oura gives the real onsets, so we emit timed + * per-segment rows (`reconstructed: false`). + */ + sleep_phase_5_min?: string | null; +} + +/** + * Oura `daily_readiness` contributors — each a 1–100 sub-score that explains + * the headline readiness number. All optional (a record may predate a + * contributor or carry a null for a missing input). + */ +export interface OuraReadinessContributors { + activity_balance?: number | null; + body_temperature?: number | null; + hrv_balance?: number | null; + previous_day_activity?: number | null; + previous_night?: number | null; + recovery_index?: number | null; + resting_heart_rate?: number | null; + sleep_balance?: number | null; +} + +export interface OuraReadiness { + id: string; + day: string; + timestamp?: string; + score?: number | null; + contributors?: OuraReadinessContributors | null; + /** Nightly body-temperature deviation from the personal baseline, °C (signed). */ + temperature_deviation?: number | null; + /** Long-term body-temperature trend deviation, °C (signed). */ + temperature_trend_deviation?: number | null; +} + +/** Oura `daily_sleep` — the headline Sleep Score (distinct from `sleep` detail). */ +export interface OuraDailySleep { + id: string; + day: string; + timestamp?: string; + score?: number | null; +} + +/** Oura `daily_spo2` — average overnight blood-oxygen saturation. */ +export interface OuraDailySpo2 { + id: string; + day: string; + spo2_percentage?: { average?: number | null } | null; +} + +/** Oura `vO2_max` — the dedicated cardio-fitness collection, mL/(kg·min). */ +export interface OuraVo2Max { + id: string; + day: string; + timestamp?: string; + vo2_max?: number | null; +} + +/** + * Oura `daily_cardiovascular_age` — Oura's estimate of how the user's + * cardiovascular system is ageing relative to chronological age, in years. + * Maps cleanly onto the existing `VASCULAR_AGE` enum (the same years-unit + * arterial-age concept Withings' Body Scan reports under meastype 155). + */ +export interface OuraCardiovascularAge { + id?: string; + day: string; + /** Estimated vascular age in years. */ + vascular_age?: number | null; +} + +/** + * Oura `daily_resilience` — the daily resilience LEVEL, a categorical band + * describing how well the body copes with cumulative load. The headline is the + * `level` string (limited / adequate / solid / strong / exceptional); the + * collection also carries `contributors` (sleep_recovery / daytime_recovery / + * stress) which we do NOT ingest — we capture only the headline level. + * Re-verify the `level` field name + value set against + * `cloud.ouraring.com/v2/docs` at build time. + */ +export interface OuraResilience { + id?: string; + day: string; + /** limited | adequate | solid | strong | exceptional */ + level?: string | null; +} + +/** + * Oura `daily_cycle_phases` — the per-day Cycle Insights phase estimate + * (temperature-deviation-derived). UNDOCUMENTED in the published OpenAPI + * spec at `cloud.ouraring.com/v2/docs` as of this writing — the route was + * confirmed live against the production API (`GET + * /v2/usercollection/daily_cycle_phases` 400s "Token is missing" identically + * to every documented `daily_*` collection when called unauthenticated, vs a + * genuine 404 for an unknown path) but sits outside the spec dump alongside + * every other Oura reproductive-health resource (`period_start`, + * `pregnancy`, `fertile_window`, `ovulation_confirmed`, `blood_glucose` — all + * listed as valid webhook subscription data types, none with a documented + * read schema). These look gated behind an approval Oura does not extend to + * a self-registered OAuth app by default, so a 403/404 here is the EXPECTED + * outcome for most connections, not an anomaly — see `syncUserOuraCyclePhases` + * in `./cycle-sync` for how that is swallowed without touching the rest of + * the Oura connection's health status. Re-verify the `phase` field name + + * value set against `cloud.ouraring.com/v2/docs` once Oura documents it. + */ +export interface OuraCyclePhase { + id?: string; + day: string; + /** + * Oura's public Cycle Insights copy describes exactly two ring-observable + * phases (`follicular` / `luteal`, temperature-derived); some algorithm + * versions may additionally emit `menstrual` directly. Kept as a loose + * string — an unrecognised / missing value is skipped, never guessed (see + * `derivePeriodDaysFromCyclePhases`). + */ + phase?: string | null; +} + +export interface OuraDailyActivity { + id: string; + day: string; + timestamp?: string; + steps?: number | null; + active_calories?: number | null; + total_calories?: number | null; + /** + * Oura's activity-equivalent distance, metres: what a walk of the day's + * effort would have covered. Not distance travelled; never imported (see + * `mapDailyActivity`). + */ + equivalent_walking_distance?: number | null; +} + +/** + * Oura `workout` — one activity session, auto-detected or entered. `activity` + * is Oura's own lowercase label (`walking`, `cycling`, `strength_training`, + * ...); `distance` is metres actually covered (a GPS or step-derived figure, + * unlike the daily activity-equivalent distance); `calories` is kcal. + */ +export interface OuraWorkout { + id: string; + day?: string; + activity?: string | null; + calories?: number | null; + distance?: number | null; + start_datetime: string; + end_datetime: string; + /** `easy` | `moderate` | `hard` */ + intensity?: string | null; + /** `manual` | `autodetected` | `confirmed` | `workout_heart_rate` */ + source?: string | null; + /** A name the person gave the workout. Free text; never imported. */ + label?: string | null; +} + +/** One `heartrate` sample: a reading at an instant, tagged with its context. */ +export interface OuraHeartRateSample { + bpm?: number | null; + /** `awake` | `rest` | `sleep` | `session` | `live` | `workout` */ + source?: string | null; + timestamp?: string | null; +} diff --git a/src/lib/polar/__tests__/__fixtures__/activity.json b/src/lib/polar/__tests__/__fixtures__/activity.json new file mode 100644 index 000000000..a636a05a4 --- /dev/null +++ b/src/lib/polar/__tests__/__fixtures__/activity.json @@ -0,0 +1,35 @@ +[ + { + "start_time": "2026-03-02T00:00:00", + "active_calories": 512.345, + "steps": 9876, + "calories": 2450, + "distance_from_steps": 7123.456 + }, + { + "start_time": "2026-03-03T00:00:00.000", + "active_calories": 0, + "steps": 0, + "distance_from_steps": 0 + }, + { + "start_time": "2026-03-04", + "steps": 4321.6, + "active_calories": null, + "distance_from_steps": null + }, + { + "start_time": "2026-03-05T00:00:00", + "steps": -5, + "active_calories": -10, + "distance_from_steps": -1 + }, + { + "active_calories": 300, + "steps": 5000 + }, + { + "start_time": "garbled-day", + "steps": 1000 + } +] diff --git a/src/lib/polar/__tests__/__fixtures__/cardio-load.json b/src/lib/polar/__tests__/__fixtures__/cardio-load.json new file mode 100644 index 000000000..581690768 --- /dev/null +++ b/src/lib/polar/__tests__/__fixtures__/cardio-load.json @@ -0,0 +1,14 @@ +[ + { + "date": "2026-03-02", + "cardio_load": 87.654, + "strain": 1.2, + "tolerance": 0.9, + "cardio_load_ratio": 1.33 + }, + { "date": "2026-03-03", "cardio_load": 0 }, + { "date": "2026-03-04", "cardio_load": null, "strain": 0.4 }, + { "date": "2026-03-05", "cardio_load": -3 }, + { "date": "2026-03-06" }, + { "date": "not-a-date", "cardio_load": 50 } +] diff --git a/src/lib/polar/__tests__/__fixtures__/exercises.json b/src/lib/polar/__tests__/__fixtures__/exercises.json new file mode 100644 index 000000000..7fad9127c --- /dev/null +++ b/src/lib/polar/__tests__/__fixtures__/exercises.json @@ -0,0 +1,53 @@ +[ + { + "id": "exercise-0001", + "start_time": "2026-03-02T07:00:00", + "start_time_utc_offset": 60, + "duration": "PT1H2M30S", + "sport": "RUNNING", + "detailed_sport_info": "TRAIL_RUNNING", + "calories": 645.5, + "distance": 10250.4, + "heart_rate": { "average": 148.6, "maximum": 176 }, + "training_load": 112.3, + "device": "Polar Vantage V3", + "device_id": "device-0001" + }, + { + "id": 4200017, + "start_time": "2026-03-03T18:30:00-05:00", + "start_time_utc_offset": -300, + "duration": "PT45M", + "sport": "CYCLING", + "calories": 0, + "distance": 0, + "heart_rate": { "average": 0, "maximum": null } + }, + { + "id": "exercise-0003", + "start_time": "2026-03-04T06:15:00Z", + "duration": "P1DT2H", + "detailed_sport_info": "SOMETHING_POLAR_INVENTED", + "heart_rate": null + }, + { + "id": "exercise-0004", + "start_time": "2026-03-05T12:00:00", + "duration": "not-a-duration", + "sport": "STRENGTH_TRAINING", + "calories": -5, + "distance": null, + "training_load": null + }, + { + "id": "exercise-0005", + "start_time": "2026-03-06T09:00:00", + "start_time_utc_offset": 330, + "duration": "PT30M15.6S", + "sport": "OTHER" + }, + { "id": null, "start_time": "2026-03-07T08:00:00", "duration": "PT20M" }, + { "id": " ", "start_time": "2026-03-07T08:00:00", "duration": "PT20M" }, + { "id": "exercise-0008", "start_time": "garbled", "duration": "PT20M" }, + { "id": "exercise-0009", "duration": "PT20M", "sport": "WALKING" } +] diff --git a/src/lib/polar/__tests__/__fixtures__/nightly-recharge.json b/src/lib/polar/__tests__/__fixtures__/nightly-recharge.json new file mode 100644 index 000000000..476531b95 --- /dev/null +++ b/src/lib/polar/__tests__/__fixtures__/nightly-recharge.json @@ -0,0 +1,45 @@ +[ + { + "date": "2026-03-02", + "nightly_recharge_status": 4, + "ans_charge": 3.456, + "ans_charge_status": 3, + "heart_rate_avg": 55.6, + "heart_rate_variability_avg": 42.123, + "breathing_rate_avg": 14.25 + }, + { + "date": "2026-03-03", + "nightly_recharge_status": 1, + "ans_charge": -7.2, + "heart_rate_avg": 0, + "heart_rate_variability_avg": 0, + "breathing_rate_avg": 0 + }, + { + "date": "2026-03-04", + "nightly_recharge_status": 6, + "ans_charge": 0 + }, + { + "date": "2026-03-05", + "nightly_recharge_status": 9, + "heart_rate_avg": 61 + }, + { + "date": "2026-03-06", + "nightly_recharge_status": 0, + "ans_charge": null, + "heart_rate_avg": null, + "heart_rate_variability_avg": 38, + "breathing_rate_avg": null + }, + { + "date": "2026-03-07" + }, + { + "date": "not-a-date", + "nightly_recharge_status": 3, + "heart_rate_avg": 58 + } +] diff --git a/src/lib/polar/__tests__/__fixtures__/sleep.json b/src/lib/polar/__tests__/__fixtures__/sleep.json new file mode 100644 index 000000000..546e92e89 --- /dev/null +++ b/src/lib/polar/__tests__/__fixtures__/sleep.json @@ -0,0 +1,63 @@ +[ + { + "date": "2026-03-02", + "sleep_start_time": "2026-03-01T23:10:00+01:00", + "sleep_end_time": "2026-03-02T07:05:00+01:00", + "light_sleep": 14400, + "deep_sleep": 5400, + "rem_sleep": 6300, + "total_interruption_duration": 2400, + "sleep_charge": 3, + "sleep_score": 82.5, + "heart_rate_samples": { "00:00": 58, "00:05": 56 } + }, + { + "date": "2026-03-03", + "sleep_start_time": "2026-03-02T22:30:00-05:00", + "sleep_end_time": "2026-03-03T06:00:00-05:00", + "light_sleep": 16200, + "deep_sleep": 0, + "rem_sleep": 7200, + "total_interruption_duration": 0 + }, + { + "date": "2026-03-04", + "light_sleep": 15000, + "deep_sleep": 4800, + "rem_sleep": 6000, + "total_interruption_duration": 1234, + "sleep_score": 76 + }, + { + "date": "2026-03-05", + "sleep_start_time": "2026-03-05T06:00:00Z", + "sleep_end_time": "2026-03-05T06:00:00Z", + "light_sleep": 0, + "deep_sleep": null, + "rem_sleep": 3000, + "sleep_score": null + }, + { + "date": "2026-03-06", + "sleep_start_time": "garbled", + "sleep_end_time": "2026-03-06T06:30:00Z", + "light_sleep": 13500, + "rem_sleep": 5400, + "total_interruption_duration": 900, + "sleep_score": 70 + }, + { + "date": "2026-03-07", + "sleep_start_time": "2026-03-06T23:00:00Z", + "sleep_end_time": "2026-03-07T06:45:00Z", + "light_sleep": 12345.6, + "deep_sleep": 4567, + "rem_sleep": 5678, + "total_interruption_duration": null, + "sleep_score": 0 + }, + { + "date": "not-a-date", + "light_sleep": 14000 + } +] diff --git a/src/lib/polar/__tests__/__fixtures__/spo2.json b/src/lib/polar/__tests__/__fixtures__/spo2.json new file mode 100644 index 000000000..a50b51ec4 --- /dev/null +++ b/src/lib/polar/__tests__/__fixtures__/spo2.json @@ -0,0 +1,24 @@ +[ + { + "source_device_id": "device-0001", + "test_time": 1772438400, + "blood_oxygen_percent": 97.456 + }, + { "test_time": 1772524800, "blood_oxygen_percent": 100 }, + { + "source_device_id": "device-0001", + "test_time": 1772611200, + "blood_oxygen_percent": 0 + }, + { + "source_device_id": "device-0001", + "test_time": 1772697600, + "blood_oxygen_percent": 100.5 + }, + { + "source_device_id": "device-0002", + "test_time": 1772784000, + "blood_oxygen_percent": null + }, + { "source_device_id": "device-0002", "blood_oxygen_percent": 95 } +] diff --git a/src/lib/polar/__tests__/__golden__/map-activity.json b/src/lib/polar/__tests__/__golden__/map-activity.json new file mode 100644 index 000000000..74c5117f6 --- /dev/null +++ b/src/lib/polar/__tests__/__golden__/map-activity.json @@ -0,0 +1,260 @@ +[ + { + "record": "2026-03-02T00:00:00", + "byZone": [ + { + "tz": null, + "rows": [ + { + "type": "ACTIVITY_STEPS", + "value": 9876, + "unit": "steps", + "measuredAt": "2026-03-02T12:00:00.000Z", + "fieldTag": "steps", + "day": "2026-03-02" + }, + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 512.35, + "unit": "kcal", + "measuredAt": "2026-03-02T12:00:00.000Z", + "fieldTag": "active_energy", + "day": "2026-03-02" + }, + { + "type": "WALKING_RUNNING_DISTANCE", + "value": 7123.46, + "unit": "meters", + "measuredAt": "2026-03-02T12:00:00.000Z", + "fieldTag": "distance", + "day": "2026-03-02" + } + ] + }, + { + "tz": "Europe/Berlin", + "rows": [ + { + "type": "ACTIVITY_STEPS", + "value": 9876, + "unit": "steps", + "measuredAt": "2026-03-02T11:00:00.000Z", + "fieldTag": "steps", + "day": "2026-03-02" + }, + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 512.35, + "unit": "kcal", + "measuredAt": "2026-03-02T11:00:00.000Z", + "fieldTag": "active_energy", + "day": "2026-03-02" + }, + { + "type": "WALKING_RUNNING_DISTANCE", + "value": 7123.46, + "unit": "meters", + "measuredAt": "2026-03-02T11:00:00.000Z", + "fieldTag": "distance", + "day": "2026-03-02" + } + ] + }, + { + "tz": "America/New_York", + "rows": [ + { + "type": "ACTIVITY_STEPS", + "value": 9876, + "unit": "steps", + "measuredAt": "2026-03-02T17:00:00.000Z", + "fieldTag": "steps", + "day": "2026-03-02" + }, + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 512.35, + "unit": "kcal", + "measuredAt": "2026-03-02T17:00:00.000Z", + "fieldTag": "active_energy", + "day": "2026-03-02" + }, + { + "type": "WALKING_RUNNING_DISTANCE", + "value": 7123.46, + "unit": "meters", + "measuredAt": "2026-03-02T17:00:00.000Z", + "fieldTag": "distance", + "day": "2026-03-02" + } + ] + } + ] + }, + { + "record": "2026-03-03T00:00:00.000", + "byZone": [ + { + "tz": null, + "rows": [ + { + "type": "ACTIVITY_STEPS", + "value": 0, + "unit": "steps", + "measuredAt": "2026-03-03T12:00:00.000Z", + "fieldTag": "steps", + "day": "2026-03-03" + }, + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 0, + "unit": "kcal", + "measuredAt": "2026-03-03T12:00:00.000Z", + "fieldTag": "active_energy", + "day": "2026-03-03" + }, + { + "type": "WALKING_RUNNING_DISTANCE", + "value": 0, + "unit": "meters", + "measuredAt": "2026-03-03T12:00:00.000Z", + "fieldTag": "distance", + "day": "2026-03-03" + } + ] + }, + { + "tz": "Europe/Berlin", + "rows": [ + { + "type": "ACTIVITY_STEPS", + "value": 0, + "unit": "steps", + "measuredAt": "2026-03-03T11:00:00.000Z", + "fieldTag": "steps", + "day": "2026-03-03" + }, + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 0, + "unit": "kcal", + "measuredAt": "2026-03-03T11:00:00.000Z", + "fieldTag": "active_energy", + "day": "2026-03-03" + }, + { + "type": "WALKING_RUNNING_DISTANCE", + "value": 0, + "unit": "meters", + "measuredAt": "2026-03-03T11:00:00.000Z", + "fieldTag": "distance", + "day": "2026-03-03" + } + ] + }, + { + "tz": "America/New_York", + "rows": [ + { + "type": "ACTIVITY_STEPS", + "value": 0, + "unit": "steps", + "measuredAt": "2026-03-03T17:00:00.000Z", + "fieldTag": "steps", + "day": "2026-03-03" + }, + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 0, + "unit": "kcal", + "measuredAt": "2026-03-03T17:00:00.000Z", + "fieldTag": "active_energy", + "day": "2026-03-03" + }, + { + "type": "WALKING_RUNNING_DISTANCE", + "value": 0, + "unit": "meters", + "measuredAt": "2026-03-03T17:00:00.000Z", + "fieldTag": "distance", + "day": "2026-03-03" + } + ] + } + ] + }, + { + "record": "2026-03-04", + "byZone": [ + { + "tz": null, + "rows": [ + { + "type": "ACTIVITY_STEPS", + "value": 4322, + "unit": "steps", + "measuredAt": "2026-03-04T12:00:00.000Z", + "fieldTag": "steps", + "day": "2026-03-04" + } + ] + }, + { + "tz": "Europe/Berlin", + "rows": [ + { + "type": "ACTIVITY_STEPS", + "value": 4322, + "unit": "steps", + "measuredAt": "2026-03-04T11:00:00.000Z", + "fieldTag": "steps", + "day": "2026-03-04" + } + ] + }, + { + "tz": "America/New_York", + "rows": [ + { + "type": "ACTIVITY_STEPS", + "value": 4322, + "unit": "steps", + "measuredAt": "2026-03-04T17:00:00.000Z", + "fieldTag": "steps", + "day": "2026-03-04" + } + ] + } + ] + }, + { + "record": "2026-03-05T00:00:00", + "byZone": [ + { "tz": null, "rows": [] }, + { "tz": "Europe/Berlin", "rows": [] }, + { "tz": "America/New_York", "rows": [] } + ] + }, + { + "record": "(no start_time)", + "byZone": [ + { "tz": null, "rows": [] }, + { "tz": "Europe/Berlin", "rows": [] }, + { "tz": "America/New_York", "rows": [] } + ] + }, + { + "record": "garbled-day", + "byZone": [ + { "tz": null, "rows": [] }, + { + "tz": "Europe/Berlin", + "rows": { "threw": "RangeError: Invalid time value" } + }, + { + "tz": "America/New_York", + "rows": { "threw": "RangeError: Invalid time value" } + } + ] + } +] diff --git a/src/lib/polar/__tests__/__golden__/map-cardio-load.json b/src/lib/polar/__tests__/__golden__/map-cardio-load.json new file mode 100644 index 000000000..5de9a25db --- /dev/null +++ b/src/lib/polar/__tests__/__golden__/map-cardio-load.json @@ -0,0 +1,128 @@ +[ + { + "record": "2026-03-02", + "byZone": [ + { + "tz": null, + "rows": [ + { + "type": "CARDIO_LOAD", + "value": 87.65, + "unit": "score", + "measuredAt": "2026-03-02T12:00:00.000Z", + "fieldTag": "cardio_load", + "day": "2026-03-02" + } + ] + }, + { + "tz": "Europe/Berlin", + "rows": [ + { + "type": "CARDIO_LOAD", + "value": 87.65, + "unit": "score", + "measuredAt": "2026-03-02T11:00:00.000Z", + "fieldTag": "cardio_load", + "day": "2026-03-02" + } + ] + }, + { + "tz": "America/New_York", + "rows": [ + { + "type": "CARDIO_LOAD", + "value": 87.65, + "unit": "score", + "measuredAt": "2026-03-02T17:00:00.000Z", + "fieldTag": "cardio_load", + "day": "2026-03-02" + } + ] + } + ] + }, + { + "record": "2026-03-03", + "byZone": [ + { + "tz": null, + "rows": [ + { + "type": "CARDIO_LOAD", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-03T12:00:00.000Z", + "fieldTag": "cardio_load", + "day": "2026-03-03" + } + ] + }, + { + "tz": "Europe/Berlin", + "rows": [ + { + "type": "CARDIO_LOAD", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-03T11:00:00.000Z", + "fieldTag": "cardio_load", + "day": "2026-03-03" + } + ] + }, + { + "tz": "America/New_York", + "rows": [ + { + "type": "CARDIO_LOAD", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-03T17:00:00.000Z", + "fieldTag": "cardio_load", + "day": "2026-03-03" + } + ] + } + ] + }, + { + "record": "2026-03-04", + "byZone": [ + { "tz": null, "rows": [] }, + { "tz": "Europe/Berlin", "rows": [] }, + { "tz": "America/New_York", "rows": [] } + ] + }, + { + "record": "2026-03-05", + "byZone": [ + { "tz": null, "rows": [] }, + { "tz": "Europe/Berlin", "rows": [] }, + { "tz": "America/New_York", "rows": [] } + ] + }, + { + "record": "2026-03-06", + "byZone": [ + { "tz": null, "rows": [] }, + { "tz": "Europe/Berlin", "rows": [] }, + { "tz": "America/New_York", "rows": [] } + ] + }, + { + "record": "not-a-date", + "byZone": [ + { "tz": null, "rows": [] }, + { + "tz": "Europe/Berlin", + "rows": { "threw": "RangeError: Invalid time value" } + }, + { + "tz": "America/New_York", + "rows": { "threw": "RangeError: Invalid time value" } + } + ] + } +] diff --git a/src/lib/polar/__tests__/__golden__/map-exercises.json b/src/lib/polar/__tests__/__golden__/map-exercises.json new file mode 100644 index 000000000..b6622c47e --- /dev/null +++ b/src/lib/polar/__tests__/__golden__/map-exercises.json @@ -0,0 +1,93 @@ +[ + { + "id": "exercise-0001", + "row": { + "externalId": "exercise-0001", + "sportType": "running", + "startedAt": "2026-03-02T06:00:00.000Z", + "endedAt": "2026-03-02T07:02:30.000Z", + "durationSec": 3750, + "totalEnergyKcal": 645.5, + "totalDistanceM": 10250.4, + "avgHeartRate": 149, + "maxHeartRate": 176, + "elevationM": null, + "metadata": { + "polarSport": "RUNNING", + "polarDetailedSportInfo": "TRAIL_RUNNING", + "polarDevice": "Polar Vantage V3", + "polarDeviceId": "device-0001", + "trainingLoad": 112.3, + "startTimeUtcOffset": 60 + } + } + }, + { + "id": 4200017, + "row": { + "externalId": "4200017", + "sportType": "cycling", + "startedAt": "2026-03-03T23:30:00.000Z", + "endedAt": "2026-03-04T00:15:00.000Z", + "durationSec": 2700, + "totalEnergyKcal": 0, + "totalDistanceM": 0, + "avgHeartRate": 0, + "maxHeartRate": null, + "elevationM": null, + "metadata": { "polarSport": "CYCLING", "startTimeUtcOffset": -300 } + } + }, + { + "id": "exercise-0003", + "row": { + "externalId": "exercise-0003", + "sportType": "other", + "startedAt": "2026-03-04T06:15:00.000Z", + "endedAt": "2026-03-05T08:15:00.000Z", + "durationSec": 93600, + "totalEnergyKcal": null, + "totalDistanceM": null, + "avgHeartRate": null, + "maxHeartRate": null, + "elevationM": null, + "metadata": { "polarDetailedSportInfo": "SOMETHING_POLAR_INVENTED" } + } + }, + { + "id": "exercise-0004", + "row": { + "externalId": "exercise-0004", + "sportType": "strength", + "startedAt": "2026-03-05T12:00:00.000Z", + "endedAt": "2026-03-05T12:00:00.000Z", + "durationSec": 0, + "totalEnergyKcal": null, + "totalDistanceM": null, + "avgHeartRate": null, + "maxHeartRate": null, + "elevationM": null, + "metadata": { "polarSport": "STRENGTH_TRAINING" } + } + }, + { + "id": "exercise-0005", + "row": { + "externalId": "exercise-0005", + "sportType": "other", + "startedAt": "2026-03-06T03:30:00.000Z", + "endedAt": "2026-03-06T04:00:16.000Z", + "durationSec": 1816, + "totalEnergyKcal": null, + "totalDistanceM": null, + "avgHeartRate": null, + "maxHeartRate": null, + "elevationM": null, + "metadata": { "polarSport": "OTHER", "startTimeUtcOffset": 330 } + } + }, + { "id": null, "row": null }, + { "id": " ", "row": null }, + { "id": "exercise-0008", "row": null }, + { "id": "exercise-0009", "row": null } +] diff --git a/src/lib/polar/__tests__/__golden__/map-helpers.json b/src/lib/polar/__tests__/__golden__/map-helpers.json new file mode 100644 index 000000000..d4b40ddeb --- /dev/null +++ b/src/lib/polar/__tests__/__golden__/map-helpers.json @@ -0,0 +1,91 @@ +{ + "normaliseExerciseList": [ + { "label": "bare array", "result": [{ "id": "exercise-0001" }] }, + { "label": "exercises key", "result": [{ "id": "exercise-0001" }] }, + { "label": "data key", "result": [{ "id": "exercise-0001" }] }, + { "label": "items key", "result": [{ "id": "exercise-0001" }] }, + { "label": "records key", "result": [{ "id": "exercise-0001" }] }, + { "label": "first known key wins", "result": [{ "id": "exercise-0001" }] }, + { "label": "empty known key", "result": [] }, + { "label": "unknown key", "result": [] }, + { "label": "known key not an array", "result": [] }, + { "label": "null", "result": [] }, + { "label": "empty string", "result": [] }, + { "label": "number", "result": [] } + ], + "combinePolarStart": [ + { + "startTime": "2026-03-02T07:00:00", + "offset": "60", + "result": "2026-03-02T06:00:00.000Z" + }, + { + "startTime": "2026-03-02T07:00:00", + "offset": "-300", + "result": "2026-03-02T12:00:00.000Z" + }, + { + "startTime": "2026-03-02T07:00:00", + "offset": "0", + "result": "2026-03-02T07:00:00.000Z" + }, + { + "startTime": "2026-03-02T07:00:00", + "offset": "null", + "result": "2026-03-02T07:00:00.000Z" + }, + { + "startTime": "2026-03-02T07:00:00", + "offset": "NaN", + "result": "2026-03-02T07:00:00.000Z" + }, + { + "startTime": "2026-03-02T07:00:00.500", + "offset": "330", + "result": "2026-03-02T01:30:00.500Z" + }, + { + "startTime": "2026-03-02T07:00:00Z", + "offset": "120", + "result": "2026-03-02T07:00:00.000Z" + }, + { + "startTime": "2026-03-02T07:00:00+02:00", + "offset": "120", + "result": "2026-03-02T05:00:00.000Z" + }, + { + "startTime": "2026-03-02T07:00:00-0500", + "offset": "-300", + "result": "2026-03-02T12:00:00.000Z" + }, + { + "startTime": " 2026-03-02T07:00:00 ", + "offset": "60", + "result": "2026-03-02T06:00:00.000Z" + }, + { "startTime": "garbled", "offset": "60", "result": null }, + { "startTime": "", "offset": "60", "result": null }, + { "startTime": " ", "offset": "60", "result": null }, + { "startTime": null, "offset": "60", "result": null }, + { "startTime": null, "offset": "(undefined)", "result": null } + ], + "parseIsoDuration": [ + { "raw": "PT2H44M30S", "seconds": 9870 }, + { "raw": "PT45S", "seconds": 45 }, + { "raw": "PT45M", "seconds": 2700 }, + { "raw": "P1DT2H", "seconds": 93600 }, + { "raw": "P2D", "seconds": 172800 }, + { "raw": "PT30M15.6S", "seconds": 1816 }, + { "raw": "PT0.4S", "seconds": 0 }, + { "raw": "pt1h", "seconds": 3600 }, + { "raw": " PT10M ", "seconds": 600 }, + { "raw": "PT", "seconds": 0 }, + { "raw": "P", "seconds": 0 }, + { "raw": "PT-5M", "seconds": 0 }, + { "raw": "1:02:30", "seconds": 0 }, + { "raw": "", "seconds": 0 }, + { "raw": null, "seconds": 0 }, + { "raw": "(undefined)", "seconds": 0 } + ] +} diff --git a/src/lib/polar/__tests__/__golden__/map-nightly-recharge.json b/src/lib/polar/__tests__/__golden__/map-nightly-recharge.json new file mode 100644 index 000000000..77bc0ffae --- /dev/null +++ b/src/lib/polar/__tests__/__golden__/map-nightly-recharge.json @@ -0,0 +1,436 @@ +[ + { + "record": "2026-03-02", + "byZone": [ + { + "tz": null, + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 60, + "unit": "score", + "measuredAt": "2026-03-02T12:00:00.000Z", + "fieldTag": "recovery", + "day": "2026-03-02" + }, + { + "type": "HRV_RMSSD", + "value": 42.12, + "unit": "ms", + "measuredAt": "2026-03-02T12:00:00.000Z", + "fieldTag": "hrv_rmssd", + "day": "2026-03-02" + }, + { + "type": "RESTING_HEART_RATE", + "value": 56, + "unit": "bpm", + "measuredAt": "2026-03-02T12:00:00.000Z", + "fieldTag": "rhr", + "day": "2026-03-02" + }, + { + "type": "RESPIRATORY_RATE", + "value": 14.25, + "unit": "breaths/min", + "measuredAt": "2026-03-02T12:00:00.000Z", + "fieldTag": "resp_rate", + "day": "2026-03-02" + }, + { + "type": "ANS_CHARGE", + "value": 3.46, + "unit": "score", + "measuredAt": "2026-03-02T12:00:00.000Z", + "fieldTag": "ans_charge", + "day": "2026-03-02" + } + ] + }, + { + "tz": "Europe/Berlin", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 60, + "unit": "score", + "measuredAt": "2026-03-02T11:00:00.000Z", + "fieldTag": "recovery", + "day": "2026-03-02" + }, + { + "type": "HRV_RMSSD", + "value": 42.12, + "unit": "ms", + "measuredAt": "2026-03-02T11:00:00.000Z", + "fieldTag": "hrv_rmssd", + "day": "2026-03-02" + }, + { + "type": "RESTING_HEART_RATE", + "value": 56, + "unit": "bpm", + "measuredAt": "2026-03-02T11:00:00.000Z", + "fieldTag": "rhr", + "day": "2026-03-02" + }, + { + "type": "RESPIRATORY_RATE", + "value": 14.25, + "unit": "breaths/min", + "measuredAt": "2026-03-02T11:00:00.000Z", + "fieldTag": "resp_rate", + "day": "2026-03-02" + }, + { + "type": "ANS_CHARGE", + "value": 3.46, + "unit": "score", + "measuredAt": "2026-03-02T11:00:00.000Z", + "fieldTag": "ans_charge", + "day": "2026-03-02" + } + ] + }, + { + "tz": "America/New_York", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 60, + "unit": "score", + "measuredAt": "2026-03-02T17:00:00.000Z", + "fieldTag": "recovery", + "day": "2026-03-02" + }, + { + "type": "HRV_RMSSD", + "value": 42.12, + "unit": "ms", + "measuredAt": "2026-03-02T17:00:00.000Z", + "fieldTag": "hrv_rmssd", + "day": "2026-03-02" + }, + { + "type": "RESTING_HEART_RATE", + "value": 56, + "unit": "bpm", + "measuredAt": "2026-03-02T17:00:00.000Z", + "fieldTag": "rhr", + "day": "2026-03-02" + }, + { + "type": "RESPIRATORY_RATE", + "value": 14.25, + "unit": "breaths/min", + "measuredAt": "2026-03-02T17:00:00.000Z", + "fieldTag": "resp_rate", + "day": "2026-03-02" + }, + { + "type": "ANS_CHARGE", + "value": 3.46, + "unit": "score", + "measuredAt": "2026-03-02T17:00:00.000Z", + "fieldTag": "ans_charge", + "day": "2026-03-02" + } + ] + } + ] + }, + { + "record": "2026-03-03", + "byZone": [ + { + "tz": null, + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-03T12:00:00.000Z", + "fieldTag": "recovery", + "day": "2026-03-03" + }, + { + "type": "ANS_CHARGE", + "value": -7.2, + "unit": "score", + "measuredAt": "2026-03-03T12:00:00.000Z", + "fieldTag": "ans_charge", + "day": "2026-03-03" + } + ] + }, + { + "tz": "Europe/Berlin", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-03T11:00:00.000Z", + "fieldTag": "recovery", + "day": "2026-03-03" + }, + { + "type": "ANS_CHARGE", + "value": -7.2, + "unit": "score", + "measuredAt": "2026-03-03T11:00:00.000Z", + "fieldTag": "ans_charge", + "day": "2026-03-03" + } + ] + }, + { + "tz": "America/New_York", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-03T17:00:00.000Z", + "fieldTag": "recovery", + "day": "2026-03-03" + }, + { + "type": "ANS_CHARGE", + "value": -7.2, + "unit": "score", + "measuredAt": "2026-03-03T17:00:00.000Z", + "fieldTag": "ans_charge", + "day": "2026-03-03" + } + ] + } + ] + }, + { + "record": "2026-03-04", + "byZone": [ + { + "tz": null, + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 100, + "unit": "score", + "measuredAt": "2026-03-04T12:00:00.000Z", + "fieldTag": "recovery", + "day": "2026-03-04" + }, + { + "type": "ANS_CHARGE", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-04T12:00:00.000Z", + "fieldTag": "ans_charge", + "day": "2026-03-04" + } + ] + }, + { + "tz": "Europe/Berlin", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 100, + "unit": "score", + "measuredAt": "2026-03-04T11:00:00.000Z", + "fieldTag": "recovery", + "day": "2026-03-04" + }, + { + "type": "ANS_CHARGE", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-04T11:00:00.000Z", + "fieldTag": "ans_charge", + "day": "2026-03-04" + } + ] + }, + { + "tz": "America/New_York", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 100, + "unit": "score", + "measuredAt": "2026-03-04T17:00:00.000Z", + "fieldTag": "recovery", + "day": "2026-03-04" + }, + { + "type": "ANS_CHARGE", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-04T17:00:00.000Z", + "fieldTag": "ans_charge", + "day": "2026-03-04" + } + ] + } + ] + }, + { + "record": "2026-03-05", + "byZone": [ + { + "tz": null, + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 100, + "unit": "score", + "measuredAt": "2026-03-05T12:00:00.000Z", + "fieldTag": "recovery", + "day": "2026-03-05" + }, + { + "type": "RESTING_HEART_RATE", + "value": 61, + "unit": "bpm", + "measuredAt": "2026-03-05T12:00:00.000Z", + "fieldTag": "rhr", + "day": "2026-03-05" + } + ] + }, + { + "tz": "Europe/Berlin", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 100, + "unit": "score", + "measuredAt": "2026-03-05T11:00:00.000Z", + "fieldTag": "recovery", + "day": "2026-03-05" + }, + { + "type": "RESTING_HEART_RATE", + "value": 61, + "unit": "bpm", + "measuredAt": "2026-03-05T11:00:00.000Z", + "fieldTag": "rhr", + "day": "2026-03-05" + } + ] + }, + { + "tz": "America/New_York", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 100, + "unit": "score", + "measuredAt": "2026-03-05T17:00:00.000Z", + "fieldTag": "recovery", + "day": "2026-03-05" + }, + { + "type": "RESTING_HEART_RATE", + "value": 61, + "unit": "bpm", + "measuredAt": "2026-03-05T17:00:00.000Z", + "fieldTag": "rhr", + "day": "2026-03-05" + } + ] + } + ] + }, + { + "record": "2026-03-06", + "byZone": [ + { + "tz": null, + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-06T12:00:00.000Z", + "fieldTag": "recovery", + "day": "2026-03-06" + }, + { + "type": "HRV_RMSSD", + "value": 38, + "unit": "ms", + "measuredAt": "2026-03-06T12:00:00.000Z", + "fieldTag": "hrv_rmssd", + "day": "2026-03-06" + } + ] + }, + { + "tz": "Europe/Berlin", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-06T11:00:00.000Z", + "fieldTag": "recovery", + "day": "2026-03-06" + }, + { + "type": "HRV_RMSSD", + "value": 38, + "unit": "ms", + "measuredAt": "2026-03-06T11:00:00.000Z", + "fieldTag": "hrv_rmssd", + "day": "2026-03-06" + } + ] + }, + { + "tz": "America/New_York", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-06T17:00:00.000Z", + "fieldTag": "recovery", + "day": "2026-03-06" + }, + { + "type": "HRV_RMSSD", + "value": 38, + "unit": "ms", + "measuredAt": "2026-03-06T17:00:00.000Z", + "fieldTag": "hrv_rmssd", + "day": "2026-03-06" + } + ] + } + ] + }, + { + "record": "2026-03-07", + "byZone": [ + { "tz": null, "rows": [] }, + { "tz": "Europe/Berlin", "rows": [] }, + { "tz": "America/New_York", "rows": [] } + ] + }, + { + "record": "not-a-date", + "byZone": [ + { "tz": null, "rows": [] }, + { + "tz": "Europe/Berlin", + "rows": { "threw": "RangeError: Invalid time value" } + }, + { + "tz": "America/New_York", + "rows": { "threw": "RangeError: Invalid time value" } + } + ] + } +] diff --git a/src/lib/polar/__tests__/__golden__/map-sleep.json b/src/lib/polar/__tests__/__golden__/map-sleep.json new file mode 100644 index 000000000..5afdf9673 --- /dev/null +++ b/src/lib/polar/__tests__/__golden__/map-sleep.json @@ -0,0 +1,811 @@ +[ + { + "record": "2026-03-02", + "byZone": [ + { + "tz": null, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 40, + "unit": "minutes", + "measuredAt": "2026-03-01T22:50:00.000Z", + "fieldTag": "sleep_awake", + "externalId": "sleep:2026-03-02:seg:sleep_awake", + "sleepStage": "AWAKE", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 240, + "unit": "minutes", + "measuredAt": "2026-03-02T02:50:00.000Z", + "fieldTag": "sleep_core", + "externalId": "sleep:2026-03-02:seg:sleep_core", + "sleepStage": "CORE", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 90, + "unit": "minutes", + "measuredAt": "2026-03-02T04:20:00.000Z", + "fieldTag": "sleep_deep", + "externalId": "sleep:2026-03-02:seg:sleep_deep", + "sleepStage": "DEEP", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 105, + "unit": "minutes", + "measuredAt": "2026-03-02T06:05:00.000Z", + "fieldTag": "sleep_rem", + "externalId": "sleep:2026-03-02:seg:sleep_rem", + "sleepStage": "REM", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 475, + "unit": "minutes", + "measuredAt": "2026-03-02T06:05:00.000Z", + "fieldTag": "sleep_in_bed", + "sleepStage": "IN_BED" + }, + { + "type": "SLEEP_PERFORMANCE", + "value": 82.5, + "unit": "%", + "measuredAt": "2026-03-02T06:05:00.000Z", + "fieldTag": "sleep_score" + } + ] + }, + { + "tz": "Europe/Berlin", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 40, + "unit": "minutes", + "measuredAt": "2026-03-01T22:50:00.000Z", + "fieldTag": "sleep_awake", + "externalId": "sleep:2026-03-02:seg:sleep_awake", + "sleepStage": "AWAKE", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 240, + "unit": "minutes", + "measuredAt": "2026-03-02T02:50:00.000Z", + "fieldTag": "sleep_core", + "externalId": "sleep:2026-03-02:seg:sleep_core", + "sleepStage": "CORE", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 90, + "unit": "minutes", + "measuredAt": "2026-03-02T04:20:00.000Z", + "fieldTag": "sleep_deep", + "externalId": "sleep:2026-03-02:seg:sleep_deep", + "sleepStage": "DEEP", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 105, + "unit": "minutes", + "measuredAt": "2026-03-02T06:05:00.000Z", + "fieldTag": "sleep_rem", + "externalId": "sleep:2026-03-02:seg:sleep_rem", + "sleepStage": "REM", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 475, + "unit": "minutes", + "measuredAt": "2026-03-02T06:05:00.000Z", + "fieldTag": "sleep_in_bed", + "sleepStage": "IN_BED" + }, + { + "type": "SLEEP_PERFORMANCE", + "value": 82.5, + "unit": "%", + "measuredAt": "2026-03-02T06:05:00.000Z", + "fieldTag": "sleep_score" + } + ] + }, + { + "tz": "America/New_York", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 40, + "unit": "minutes", + "measuredAt": "2026-03-01T22:50:00.000Z", + "fieldTag": "sleep_awake", + "externalId": "sleep:2026-03-02:seg:sleep_awake", + "sleepStage": "AWAKE", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 240, + "unit": "minutes", + "measuredAt": "2026-03-02T02:50:00.000Z", + "fieldTag": "sleep_core", + "externalId": "sleep:2026-03-02:seg:sleep_core", + "sleepStage": "CORE", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 90, + "unit": "minutes", + "measuredAt": "2026-03-02T04:20:00.000Z", + "fieldTag": "sleep_deep", + "externalId": "sleep:2026-03-02:seg:sleep_deep", + "sleepStage": "DEEP", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 105, + "unit": "minutes", + "measuredAt": "2026-03-02T06:05:00.000Z", + "fieldTag": "sleep_rem", + "externalId": "sleep:2026-03-02:seg:sleep_rem", + "sleepStage": "REM", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 475, + "unit": "minutes", + "measuredAt": "2026-03-02T06:05:00.000Z", + "fieldTag": "sleep_in_bed", + "sleepStage": "IN_BED" + }, + { + "type": "SLEEP_PERFORMANCE", + "value": 82.5, + "unit": "%", + "measuredAt": "2026-03-02T06:05:00.000Z", + "fieldTag": "sleep_score" + } + ] + } + ] + }, + { + "record": "2026-03-03", + "byZone": [ + { + "tz": null, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 270, + "unit": "minutes", + "measuredAt": "2026-03-03T08:00:00.000Z", + "fieldTag": "sleep_core", + "externalId": "sleep:2026-03-03:seg:sleep_core", + "sleepStage": "CORE", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 120, + "unit": "minutes", + "measuredAt": "2026-03-03T10:00:00.000Z", + "fieldTag": "sleep_rem", + "externalId": "sleep:2026-03-03:seg:sleep_rem", + "sleepStage": "REM", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 450, + "unit": "minutes", + "measuredAt": "2026-03-03T11:00:00.000Z", + "fieldTag": "sleep_in_bed", + "sleepStage": "IN_BED" + } + ] + }, + { + "tz": "Europe/Berlin", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 270, + "unit": "minutes", + "measuredAt": "2026-03-03T08:00:00.000Z", + "fieldTag": "sleep_core", + "externalId": "sleep:2026-03-03:seg:sleep_core", + "sleepStage": "CORE", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 120, + "unit": "minutes", + "measuredAt": "2026-03-03T10:00:00.000Z", + "fieldTag": "sleep_rem", + "externalId": "sleep:2026-03-03:seg:sleep_rem", + "sleepStage": "REM", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 450, + "unit": "minutes", + "measuredAt": "2026-03-03T11:00:00.000Z", + "fieldTag": "sleep_in_bed", + "sleepStage": "IN_BED" + } + ] + }, + { + "tz": "America/New_York", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 270, + "unit": "minutes", + "measuredAt": "2026-03-03T08:00:00.000Z", + "fieldTag": "sleep_core", + "externalId": "sleep:2026-03-03:seg:sleep_core", + "sleepStage": "CORE", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 120, + "unit": "minutes", + "measuredAt": "2026-03-03T10:00:00.000Z", + "fieldTag": "sleep_rem", + "externalId": "sleep:2026-03-03:seg:sleep_rem", + "sleepStage": "REM", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 450, + "unit": "minutes", + "measuredAt": "2026-03-03T11:00:00.000Z", + "fieldTag": "sleep_in_bed", + "sleepStage": "IN_BED" + } + ] + } + ] + }, + { + "record": "2026-03-04", + "byZone": [ + { + "tz": null, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 20.57, + "unit": "minutes", + "measuredAt": "2026-03-04T12:00:00.000Z", + "fieldTag": "sleep_awake", + "sleepStage": "AWAKE", + "day": "2026-03-04" + }, + { + "type": "SLEEP_DURATION", + "value": 250, + "unit": "minutes", + "measuredAt": "2026-03-04T12:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "day": "2026-03-04" + }, + { + "type": "SLEEP_DURATION", + "value": 80, + "unit": "minutes", + "measuredAt": "2026-03-04T12:00:00.000Z", + "fieldTag": "sleep_deep", + "sleepStage": "DEEP", + "day": "2026-03-04" + }, + { + "type": "SLEEP_DURATION", + "value": 100, + "unit": "minutes", + "measuredAt": "2026-03-04T12:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "day": "2026-03-04" + }, + { + "type": "SLEEP_PERFORMANCE", + "value": 76, + "unit": "%", + "measuredAt": "2026-03-04T12:00:00.000Z", + "fieldTag": "sleep_score", + "day": "2026-03-04" + } + ] + }, + { + "tz": "Europe/Berlin", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 20.57, + "unit": "minutes", + "measuredAt": "2026-03-04T11:00:00.000Z", + "fieldTag": "sleep_awake", + "sleepStage": "AWAKE", + "day": "2026-03-04" + }, + { + "type": "SLEEP_DURATION", + "value": 250, + "unit": "minutes", + "measuredAt": "2026-03-04T11:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "day": "2026-03-04" + }, + { + "type": "SLEEP_DURATION", + "value": 80, + "unit": "minutes", + "measuredAt": "2026-03-04T11:00:00.000Z", + "fieldTag": "sleep_deep", + "sleepStage": "DEEP", + "day": "2026-03-04" + }, + { + "type": "SLEEP_DURATION", + "value": 100, + "unit": "minutes", + "measuredAt": "2026-03-04T11:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "day": "2026-03-04" + }, + { + "type": "SLEEP_PERFORMANCE", + "value": 76, + "unit": "%", + "measuredAt": "2026-03-04T11:00:00.000Z", + "fieldTag": "sleep_score", + "day": "2026-03-04" + } + ] + }, + { + "tz": "America/New_York", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 20.57, + "unit": "minutes", + "measuredAt": "2026-03-04T17:00:00.000Z", + "fieldTag": "sleep_awake", + "sleepStage": "AWAKE", + "day": "2026-03-04" + }, + { + "type": "SLEEP_DURATION", + "value": 250, + "unit": "minutes", + "measuredAt": "2026-03-04T17:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "day": "2026-03-04" + }, + { + "type": "SLEEP_DURATION", + "value": 80, + "unit": "minutes", + "measuredAt": "2026-03-04T17:00:00.000Z", + "fieldTag": "sleep_deep", + "sleepStage": "DEEP", + "day": "2026-03-04" + }, + { + "type": "SLEEP_DURATION", + "value": 100, + "unit": "minutes", + "measuredAt": "2026-03-04T17:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "day": "2026-03-04" + }, + { + "type": "SLEEP_PERFORMANCE", + "value": 76, + "unit": "%", + "measuredAt": "2026-03-04T17:00:00.000Z", + "fieldTag": "sleep_score", + "day": "2026-03-04" + } + ] + } + ] + }, + { + "record": "2026-03-05", + "byZone": [ + { + "tz": null, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 0, + "unit": "minutes", + "measuredAt": "2026-03-05T12:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "day": "2026-03-05" + }, + { + "type": "SLEEP_DURATION", + "value": 50, + "unit": "minutes", + "measuredAt": "2026-03-05T12:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "day": "2026-03-05" + } + ] + }, + { + "tz": "Europe/Berlin", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 0, + "unit": "minutes", + "measuredAt": "2026-03-05T11:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "day": "2026-03-05" + }, + { + "type": "SLEEP_DURATION", + "value": 50, + "unit": "minutes", + "measuredAt": "2026-03-05T11:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "day": "2026-03-05" + } + ] + }, + { + "tz": "America/New_York", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 0, + "unit": "minutes", + "measuredAt": "2026-03-05T17:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "day": "2026-03-05" + }, + { + "type": "SLEEP_DURATION", + "value": 50, + "unit": "minutes", + "measuredAt": "2026-03-05T17:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "day": "2026-03-05" + } + ] + } + ] + }, + { + "record": "2026-03-06", + "byZone": [ + { + "tz": null, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 15, + "unit": "minutes", + "measuredAt": "2026-03-06T12:00:00.000Z", + "fieldTag": "sleep_awake", + "sleepStage": "AWAKE", + "day": "2026-03-06" + }, + { + "type": "SLEEP_DURATION", + "value": 225, + "unit": "minutes", + "measuredAt": "2026-03-06T12:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "day": "2026-03-06" + }, + { + "type": "SLEEP_DURATION", + "value": 90, + "unit": "minutes", + "measuredAt": "2026-03-06T12:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "day": "2026-03-06" + }, + { + "type": "SLEEP_PERFORMANCE", + "value": 70, + "unit": "%", + "measuredAt": "2026-03-06T12:00:00.000Z", + "fieldTag": "sleep_score", + "day": "2026-03-06" + } + ] + }, + { + "tz": "Europe/Berlin", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 15, + "unit": "minutes", + "measuredAt": "2026-03-06T11:00:00.000Z", + "fieldTag": "sleep_awake", + "sleepStage": "AWAKE", + "day": "2026-03-06" + }, + { + "type": "SLEEP_DURATION", + "value": 225, + "unit": "minutes", + "measuredAt": "2026-03-06T11:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "day": "2026-03-06" + }, + { + "type": "SLEEP_DURATION", + "value": 90, + "unit": "minutes", + "measuredAt": "2026-03-06T11:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "day": "2026-03-06" + }, + { + "type": "SLEEP_PERFORMANCE", + "value": 70, + "unit": "%", + "measuredAt": "2026-03-06T11:00:00.000Z", + "fieldTag": "sleep_score", + "day": "2026-03-06" + } + ] + }, + { + "tz": "America/New_York", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 15, + "unit": "minutes", + "measuredAt": "2026-03-06T17:00:00.000Z", + "fieldTag": "sleep_awake", + "sleepStage": "AWAKE", + "day": "2026-03-06" + }, + { + "type": "SLEEP_DURATION", + "value": 225, + "unit": "minutes", + "measuredAt": "2026-03-06T17:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "day": "2026-03-06" + }, + { + "type": "SLEEP_DURATION", + "value": 90, + "unit": "minutes", + "measuredAt": "2026-03-06T17:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "day": "2026-03-06" + }, + { + "type": "SLEEP_PERFORMANCE", + "value": 70, + "unit": "%", + "measuredAt": "2026-03-06T17:00:00.000Z", + "fieldTag": "sleep_score", + "day": "2026-03-06" + } + ] + } + ] + }, + { + "record": "2026-03-07", + "byZone": [ + { + "tz": null, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 205.76, + "unit": "minutes", + "measuredAt": "2026-03-07T02:25:45.600Z", + "fieldTag": "sleep_core", + "externalId": "sleep:2026-03-07:seg:sleep_core", + "sleepStage": "CORE", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 76.12, + "unit": "minutes", + "measuredAt": "2026-03-07T03:41:52.600Z", + "fieldTag": "sleep_deep", + "externalId": "sleep:2026-03-07:seg:sleep_deep", + "sleepStage": "DEEP", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 94.63, + "unit": "minutes", + "measuredAt": "2026-03-07T05:16:30.600Z", + "fieldTag": "sleep_rem", + "externalId": "sleep:2026-03-07:seg:sleep_rem", + "sleepStage": "REM", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 465, + "unit": "minutes", + "measuredAt": "2026-03-07T06:45:00.000Z", + "fieldTag": "sleep_in_bed", + "sleepStage": "IN_BED" + }, + { + "type": "SLEEP_PERFORMANCE", + "value": 0, + "unit": "%", + "measuredAt": "2026-03-07T06:45:00.000Z", + "fieldTag": "sleep_score" + } + ] + }, + { + "tz": "Europe/Berlin", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 205.76, + "unit": "minutes", + "measuredAt": "2026-03-07T02:25:45.600Z", + "fieldTag": "sleep_core", + "externalId": "sleep:2026-03-07:seg:sleep_core", + "sleepStage": "CORE", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 76.12, + "unit": "minutes", + "measuredAt": "2026-03-07T03:41:52.600Z", + "fieldTag": "sleep_deep", + "externalId": "sleep:2026-03-07:seg:sleep_deep", + "sleepStage": "DEEP", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 94.63, + "unit": "minutes", + "measuredAt": "2026-03-07T05:16:30.600Z", + "fieldTag": "sleep_rem", + "externalId": "sleep:2026-03-07:seg:sleep_rem", + "sleepStage": "REM", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 465, + "unit": "minutes", + "measuredAt": "2026-03-07T06:45:00.000Z", + "fieldTag": "sleep_in_bed", + "sleepStage": "IN_BED" + }, + { + "type": "SLEEP_PERFORMANCE", + "value": 0, + "unit": "%", + "measuredAt": "2026-03-07T06:45:00.000Z", + "fieldTag": "sleep_score" + } + ] + }, + { + "tz": "America/New_York", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 205.76, + "unit": "minutes", + "measuredAt": "2026-03-07T02:25:45.600Z", + "fieldTag": "sleep_core", + "externalId": "sleep:2026-03-07:seg:sleep_core", + "sleepStage": "CORE", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 76.12, + "unit": "minutes", + "measuredAt": "2026-03-07T03:41:52.600Z", + "fieldTag": "sleep_deep", + "externalId": "sleep:2026-03-07:seg:sleep_deep", + "sleepStage": "DEEP", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 94.63, + "unit": "minutes", + "measuredAt": "2026-03-07T05:16:30.600Z", + "fieldTag": "sleep_rem", + "externalId": "sleep:2026-03-07:seg:sleep_rem", + "sleepStage": "REM", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 465, + "unit": "minutes", + "measuredAt": "2026-03-07T06:45:00.000Z", + "fieldTag": "sleep_in_bed", + "sleepStage": "IN_BED" + }, + { + "type": "SLEEP_PERFORMANCE", + "value": 0, + "unit": "%", + "measuredAt": "2026-03-07T06:45:00.000Z", + "fieldTag": "sleep_score" + } + ] + } + ] + }, + { + "record": "not-a-date", + "byZone": [ + { "tz": null, "rows": [] }, + { + "tz": "Europe/Berlin", + "rows": { "threw": "RangeError: Invalid time value" } + }, + { + "tz": "America/New_York", + "rows": { "threw": "RangeError: Invalid time value" } + } + ] + } +] diff --git a/src/lib/polar/__tests__/__golden__/map-spo2.json b/src/lib/polar/__tests__/__golden__/map-spo2.json new file mode 100644 index 000000000..60e95dcbc --- /dev/null +++ b/src/lib/polar/__tests__/__golden__/map-spo2.json @@ -0,0 +1,32 @@ +[ + { + "test_time": 1772438400, + "rows": [ + { + "type": "OXYGEN_SATURATION", + "value": 97.46, + "unit": "%", + "measuredAt": "2026-03-02T08:00:00.000Z", + "fieldTag": "spo2", + "externalId": "spo2:1772438400:device-0001" + } + ] + }, + { + "test_time": 1772524800, + "rows": [ + { + "type": "OXYGEN_SATURATION", + "value": 100, + "unit": "%", + "measuredAt": "2026-03-03T08:00:00.000Z", + "fieldTag": "spo2", + "externalId": "spo2:1772524800:unknown" + } + ] + }, + { "test_time": 1772611200, "rows": [] }, + { "test_time": 1772697600, "rows": [] }, + { "test_time": 1772784000, "rows": [] }, + { "test_time": null, "rows": [] } +] diff --git a/src/lib/polar/__tests__/client-exercises.test.ts b/src/lib/polar/__tests__/client-exercises.test.ts index 60e1ee6b7..16962ace1 100644 --- a/src/lib/polar/__tests__/client-exercises.test.ts +++ b/src/lib/polar/__tests__/client-exercises.test.ts @@ -6,14 +6,14 @@ vi.mock("@/lib/logging/context", () => ({ getEvent: () => null, })); +import { fetchExercises } from "../client"; import { combinePolarStart, - fetchExercises, mapExercise, normaliseExerciseList, parseIsoDuration, - type PolarExercise, -} from "../client"; +} from "@/lib/polar/map"; +import { type PolarExercise } from "@/lib/polar/wire-types"; import { PolarApiError } from "../response-classifier"; function installFetchMock(pages: Array<{ status: number; body?: unknown }>) { diff --git a/src/lib/polar/__tests__/client.test.ts b/src/lib/polar/__tests__/client.test.ts index 97f3c0702..80b331b0a 100644 --- a/src/lib/polar/__tests__/client.test.ts +++ b/src/lib/polar/__tests__/client.test.ts @@ -7,29 +7,30 @@ vi.mock("@/lib/logging/context", () => ({ })); import { - POLAR_OAUTH_SCOPE, - exchangeCode, fetchActivities, fetchCardioLoads, fetchNightlyRecharges, fetchSleeps, fetchSpo2, - getAuthorizationUrl, - getPolarCredentials, - getPolarRedirectUri, + registerUser, +} from "../client"; +import { mapActivity, mapCardioLoad, mapNightlyRecharge, mapSleep, mapSpo2, - registerUser, +} from "@/lib/polar/map"; +import { type PolarActivity, type PolarCardioLoad, type PolarNightlyRecharge, type PolarSleep, type PolarSpo2, -} from "../client"; +} from "@/lib/polar/wire-types"; +import { POLAR_OAUTH_SCOPE, getPolarCredentials } from "@/lib/polar/oauth"; import { PolarApiError } from "../response-classifier"; +import { polarOAuth } from "../oauth"; const CREDS = { clientId: "cid", clientSecret: "csecret" }; @@ -82,7 +83,7 @@ describe("getPolarCredentials", () => { describe("getAuthorizationUrl", () => { it("builds the Polar authorize URL with scope + state", () => { - const url = getAuthorizationUrl("signed-state", CREDS); + const url = polarOAuth.authorizationUrl("signed-state", CREDS); expect(url).toContain("flow.polar.com/oauth2/authorization"); expect(url).toContain("response_type=code"); expect(url).toContain("scope=accesslink.read_all"); @@ -93,13 +94,13 @@ describe("getAuthorizationUrl", () => { describe("getPolarRedirectUri", () => { it("derives from app URL when override absent", () => { - expect(getPolarRedirectUri()).toBe( + expect(polarOAuth.redirectUri()).toBe( "https://app.example/api/polar/callback", ); }); it("honours the explicit override", () => { process.env.POLAR_REDIRECT_URI = "https://custom/cb"; - expect(getPolarRedirectUri()).toBe("https://custom/cb"); + expect(polarOAuth.redirectUri()).toBe("https://custom/cb"); }); }); @@ -111,7 +112,7 @@ describe("exchangeCode", () => { body: { access_token: "tok", token_type: "bearer", x_user_id: 42 }, }, ]); - const tok = await exchangeCode("code123", CREDS); + const tok = await polarOAuth.exchange("code123", CREDS); expect(tok.access_token).toBe("tok"); expect(tok.x_user_id).toBe(42); expect(fetchMock.mock.calls[0]![0]).toBe( @@ -130,7 +131,7 @@ describe("exchangeCode", () => { it("throws a classified PolarApiError on a 401", async () => { installFetchMock([{ status: 401, body: { error: "invalid_grant" } }]); - await expect(exchangeCode("bad", CREDS)).rejects.toBeInstanceOf( + await expect(polarOAuth.exchange("bad", CREDS)).rejects.toBeInstanceOf( PolarApiError, ); }); @@ -140,7 +141,7 @@ describe("exchangeCode", () => { // TypeError. It must surface as a classified transient integration error. it("classifies a 2xx with an empty body instead of casting null", async () => { installFetchMock([{ status: 200, body: null }]); - await expect(exchangeCode("code", CREDS)).rejects.toMatchObject({ + await expect(polarOAuth.exchange("code", CREDS)).rejects.toMatchObject({ classification: "transient", reason: "empty_token_body", }); diff --git a/src/lib/polar/__tests__/credentials.test.ts b/src/lib/polar/__tests__/credentials.test.ts index 11f8d5b22..d4306c238 100644 --- a/src/lib/polar/__tests__/credentials.test.ts +++ b/src/lib/polar/__tests__/credentials.test.ts @@ -15,7 +15,7 @@ vi.mock("@/lib/crypto", () => ({ })); // The env fallback lives in ./client; stub it so the precedence is observable. -vi.mock("../client", () => ({ +vi.mock("../oauth", () => ({ getPolarCredentials: () => getPolarCredentials(), })); diff --git a/src/lib/polar/__tests__/map-fixtures.test.ts b/src/lib/polar/__tests__/map-fixtures.test.ts new file mode 100644 index 000000000..c52c3fd59 --- /dev/null +++ b/src/lib/polar/__tests__/map-fixtures.test.ts @@ -0,0 +1,212 @@ +/** + * What each Polar mapper makes of a fixed set of payloads, pinned in + * `__golden__/`. + * + * The fixtures under `__fixtures__/` are synthetic payloads in the documented + * AccessLink wire shape: complete and sparse records, zero and negative + * values, missing or unparseable dates and sleep windows, offsets other than + * UTC, and exercise ids of either type. The date-keyed mappers run once per + * zone (none, Europe/Berlin, America/New_York), because the zone decides + * where a day's noon anchor lands. The goldens were written while the mappers + * still lived in `client.ts`, so a green run after they move proves the move + * changed nothing. + */ +import { format } from "prettier"; +import { describe, expect, it } from "vitest"; + +import { + combinePolarStart, + mapActivity, + mapCardioLoad, + mapExercise, + mapNightlyRecharge, + mapSleep, + mapSpo2, + normaliseExerciseList, + parseIsoDuration, +} from "@/lib/polar/map"; +import { + type PolarActivity, + type PolarCardioLoad, + type PolarExercise, + type PolarNightlyRecharge, + type PolarSleep, + type PolarSpo2, +} from "@/lib/polar/wire-types"; +import activityFixture from "./__fixtures__/activity.json"; +import cardioLoadFixture from "./__fixtures__/cardio-load.json"; +import exercisesFixture from "./__fixtures__/exercises.json"; +import nightlyRechargeFixture from "./__fixtures__/nightly-recharge.json"; +import sleepFixture from "./__fixtures__/sleep.json"; +import spo2Fixture from "./__fixtures__/spo2.json"; + +const recharges: readonly PolarNightlyRecharge[] = nightlyRechargeFixture; +const sleeps: readonly PolarSleep[] = sleepFixture; +const activities: readonly PolarActivity[] = activityFixture; +const cardioLoads: readonly PolarCardioLoad[] = cardioLoadFixture; +const spo2Tests: readonly PolarSpo2[] = spo2Fixture; +const exercises: readonly PolarExercise[] = exercisesFixture; + +const ZONES = [undefined, "Europe/Berlin", "America/New_York"] as const; + +/** The golden is written in the repository's JSON format, so the format check passes on it unchanged. */ +async function golden(value: unknown): Promise { + return format(JSON.stringify(value), { parser: "json" }); +} + +/** + * A mapper's result, or the error it throws. An unparseable date throws once + * a zone is in play (the noon anchor is computed through `Intl`), and that + * behaviour is part of what the golden pins. + */ +function attempt(run: () => unknown): unknown { + try { + return run(); + } catch (err) { + return { + threw: err instanceof Error ? `${err.name}: ${err.message}` : String(err), + }; + } +} + +/** Every record mapped once per zone, labelled so a diff names its input. */ +function perZone( + records: readonly T[], + label: (record: T) => string, + map: (record: T, tz?: string) => unknown, +) { + return records.map((record) => ({ + record: label(record), + byZone: ZONES.map((tz) => ({ + tz: tz ?? null, + rows: attempt(() => map(record, tz)), + })), + })); +} + +describe("Polar mappers against the fixture payloads", () => { + it("maps nightly recharges", async () => { + const output = perZone(recharges, (r) => r.date, mapNightlyRecharge); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-nightly-recharge.json", + ); + }); + + it("maps sleeps", async () => { + const output = perZone(sleeps, (s) => s.date, mapSleep); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-sleep.json", + ); + }); + + it("maps daily activity", async () => { + const output = perZone( + activities, + (a) => a.start_time ?? "(no start_time)", + mapActivity, + ); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-activity.json", + ); + }); + + it("maps cardio load", async () => { + const output = perZone(cardioLoads, (c) => c.date, mapCardioLoad); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-cardio-load.json", + ); + }); + + it("maps SpO2 tests", async () => { + const output = spo2Tests.map((record) => ({ + test_time: record.test_time ?? null, + rows: mapSpo2(record), + })); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-spo2.json", + ); + }); + + it("maps exercises", async () => { + const output = exercises.map((record) => ({ + id: record.id ?? null, + row: mapExercise(record), + })); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-exercises.json", + ); + }); + + it("normalises exercise list bodies, combines start times and parses durations", async () => { + const one = { id: "exercise-0001" }; + const bodies: Array<[string, unknown]> = [ + ["bare array", [one]], + ["exercises key", { exercises: [one] }], + ["data key", { data: [one] }], + ["items key", { items: [one] }], + ["records key", { records: [one] }], + ["first known key wins", { records: [{ id: "b" }], exercises: [one] }], + ["empty known key", { exercises: [] }], + ["unknown key", { results: [one] }], + ["known key not an array", { exercises: one }], + ["null", null], + ["empty string", ""], + ["number", 42], + ]; + const starts: Array< + [string | null | undefined, number | null | undefined] + > = [ + ["2026-03-02T07:00:00", 60], + ["2026-03-02T07:00:00", -300], + ["2026-03-02T07:00:00", 0], + ["2026-03-02T07:00:00", null], + ["2026-03-02T07:00:00", Number.NaN], + ["2026-03-02T07:00:00.500", 330], + ["2026-03-02T07:00:00Z", 120], + ["2026-03-02T07:00:00+02:00", 120], + ["2026-03-02T07:00:00-0500", -300], + [" 2026-03-02T07:00:00 ", 60], + ["garbled", 60], + ["", 60], + [" ", 60], + [null, 60], + [undefined, undefined], + ]; + const durations: Array = [ + "PT2H44M30S", + "PT45S", + "PT45M", + "P1DT2H", + "P2D", + "PT30M15.6S", + "PT0.4S", + "pt1h", + " PT10M ", + "PT", + "P", + "PT-5M", + "1:02:30", + "", + null, + undefined, + ]; + const output = { + normaliseExerciseList: bodies.map(([label, body]) => ({ + label, + result: normaliseExerciseList(body), + })), + combinePolarStart: starts.map(([startTime, offset]) => ({ + startTime: startTime ?? null, + offset: offset === undefined ? "(undefined)" : String(offset), + result: combinePolarStart(startTime, offset), + })), + parseIsoDuration: durations.map((raw) => ({ + raw: raw === undefined ? "(undefined)" : raw, + seconds: parseIsoDuration(raw), + })), + }; + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-helpers.json", + ); + }); +}); diff --git a/src/lib/polar/__tests__/parity.test.ts b/src/lib/polar/__tests__/parity.test.ts index 4dbae7f36..5c823e7a4 100644 --- a/src/lib/polar/__tests__/parity.test.ts +++ b/src/lib/polar/__tests__/parity.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from "vitest"; -import { mapExercise, type PolarExercise } from "../client"; +import { mapExercise } from "@/lib/polar/map"; +import { type PolarExercise } from "@/lib/polar/wire-types"; +import { mapActivity as mapStravaActivity } from "@/lib/strava/map"; import { - mapActivity as mapStravaActivity, type StravaSummaryActivity, type StravaDetailedActivity, -} from "@/lib/strava/client"; +} from "@/lib/strava/wire-types"; /** * Sensitive-cohort parity guard (mirrors the google-parity precedent): the SAME diff --git a/src/lib/polar/__tests__/sync-workouts.test.ts b/src/lib/polar/__tests__/sync-workouts.test.ts index 893a4a509..80fb0f3b8 100644 --- a/src/lib/polar/__tests__/sync-workouts.test.ts +++ b/src/lib/polar/__tests__/sync-workouts.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { PolarWorkoutRow } from "../client"; +import type { PolarWorkoutRow } from "@/lib/polar/map"; const { createManyAndReturn, diff --git a/src/lib/polar/__tests__/sync.test.ts b/src/lib/polar/__tests__/sync.test.ts index b4d36ef63..9681c8a58 100644 --- a/src/lib/polar/__tests__/sync.test.ts +++ b/src/lib/polar/__tests__/sync.test.ts @@ -69,9 +69,10 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({ collapseToTypeDayKeys: (rows: Array<{ type: string; measuredAt: Date }>) => rows.map((r) => ({ type: r.type, measuredAt: r.measuredAt })), recomputeBucketsForMeasurement: recomputeMock, + recomputeBucketsForKeys: recomputeMock, })); -vi.mock("@/lib/insights/comprehensive-generate", () => ({ +vi.mock("@/lib/insights/status-invalidation", () => ({ invalidateStatusInsightsForTypes: invalidateMock, })); diff --git a/src/lib/polar/client.ts b/src/lib/polar/client.ts index 042aa8183..bd7617701 100644 --- a/src/lib/polar/client.ts +++ b/src/lib/polar/client.ts @@ -1,14 +1,14 @@ /** - * v1.17.0 — Polar AccessLink v3 client for OAuth + data fetching. + * Polar AccessLink v3 transport and endpoints: user registration and + * collection reads. * Docs: https://www.polar.com/accesslink-api/ (re-verify at build). * Envelope shapes + windows below verified against the live AccessLink swagger * (`accesslink-api/swagger.yaml`) on 2026-07-24; a body that no longer matches * emits a `polar.envelope.drift` breadcrumb rather than silently reading empty. * - * Mirrors the WHOOP client structure (`src/lib/whoop/client.ts`): hand-rolled - * fetch over `safeFetch` (no SDK), an OAuth handshake, typed collection - * fetchers, and per-resource field→Measurement mappers (self-documenting; there - * is no separate reference constant). + * Hand-rolled fetch over `safeFetch` (no SDK). The wire shapes are + * `./wire-types.ts`, the record → row mapping is `./map.ts`, the OAuth + * handshake is `./oauth.ts`. * * Polar specifics that differ from WHOOP: * - The token endpoint uses HTTP Basic auth (`base64(client_id:client_secret)`) @@ -24,131 +24,22 @@ * treated as success. */ import { annotate, getEvent } from "@/lib/logging/context"; -import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz"; + import { safeFetch } from "@/lib/safe-fetch"; -import { reconstructContiguousSleepTimeline } from "@/lib/sleep/reconstruct-timeline"; -import type { Prisma } from "@/generated/prisma/client"; -import type { WorkoutSportType } from "@/lib/validations/workout"; + import { PolarApiError, classifyPolarResponse } from "./response-classifier"; -import { mapPolarSportType } from "./sport-map"; -import { envOr, envValue } from "@/lib/env"; -import { importValue } from "@/lib/integrations/import-value"; + +import type { + PolarNightlyRecharge, + PolarSleep, + PolarActivity, + PolarCardioLoad, + PolarSpo2, + PolarExercise, +} from "./wire-types"; +import { EXERCISE_LIST_KEYS, normaliseExerciseList } from "./map"; const POLAR_API_BASE = "https://www.polaraccesslink.com"; -const POLAR_OAUTH_AUTH_URL = "https://flow.polar.com/oauth2/authorization"; -const POLAR_OAUTH_TOKEN_URL = "https://polarremote.com/v2/oauth2/token"; - -export interface PolarCredentials { - clientId: string; - clientSecret: string; -} - -/** Resolve the env-configured shared OAuth credentials. This is the fallback - * the per-user BYO-key resolver (`getPolarClientCredentials`) drops to when a - * user has not stored their own AccessLink app id/secret. Returns null when - * unconfigured so the connect route can surface a clean "integration disabled" - * message. */ -export function getPolarCredentials(): PolarCredentials | null { - const clientId = process.env.POLAR_CLIENT_ID; - const clientSecret = process.env.POLAR_CLIENT_SECRET; - if (!clientId || !clientSecret) return null; - return { clientId, clientSecret }; -} - -export function getPolarRedirectUri(): string { - // An empty or blank value counts as unset: the compose whitelist - // materialises an unset var as an empty string, which must still fall - // through to the derived URI. - return envOr( - "POLAR_REDIRECT_URI", - `${envValue("NEXT_PUBLIC_APP_URL")}/api/polar/callback`, - ); -} - -/** - * Polar OAuth scope. `accesslink.read_all` grants every read collection so the - * user consents once. Space-separated on the wire. - */ -export const POLAR_OAUTH_SCOPE = "accesslink.read_all" as const; - -/** Build the browser-redirect authorization URL. `state` is the signed nonce. */ -export function getAuthorizationUrl( - state: string, - creds: PolarCredentials, -): string { - const params = new URLSearchParams({ - response_type: "code", - client_id: creds.clientId, - redirect_uri: getPolarRedirectUri(), - scope: POLAR_OAUTH_SCOPE, - state, - }); - return `${POLAR_OAUTH_AUTH_URL}?${params}`; -} - -export interface PolarTokenResponse { - access_token: string; - token_type: string; - expires_in?: number; - x_user_id: number; -} - -function basicAuthHeader(creds: PolarCredentials): string { - const raw = `${creds.clientId}:${creds.clientSecret}`; - return `Basic ${Buffer.from(raw).toString("base64")}`; -} - -/** Exchange an authorization code for the access token + `x_user_id`. */ -export async function exchangeCode( - code: string, - creds: PolarCredentials, -): Promise { - const start = performance.now(); - const res = await safeFetch(POLAR_OAUTH_TOKEN_URL, { - method: "POST", - headers: { - Authorization: basicAuthHeader(creds), - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - grant_type: "authorization_code", - code, - redirect_uri: getPolarRedirectUri(), - }).toString(), - }); - const json = await res.json().catch(() => null); - const verdict = classifyPolarResponse(res.status); - getEvent()?.addExternalCall({ - service: "polar", - method: "exchangeCode", - duration_ms: Math.round(performance.now() - start), - status: res.status, - error: verdict.classification === "success" ? undefined : verdict.reason, - }); - if (verdict.classification !== "success") { - throw new PolarApiError({ - verb: "exchangeCode", - classification: verdict.classification, - httpStatus: verdict.httpStatus, - reason: verdict.reason, - upstreamError: typeof json?.error === "string" ? json.error : undefined, - }); - } - // A 2xx with an empty or non-JSON body (captive portal, gateway 200, 204) - // leaves `json` null. Casting it straight to the token type handed callers a - // null they then dereferenced, surfacing an unclassified TypeError instead of - // a handled integration error. Narrow explicitly and classify as transient. - if (json === null || typeof json !== "object") { - throw new PolarApiError({ - verb: "exchangeCode", - classification: "transient", - httpStatus: res.status, - reason: "empty_token_body", - }); - } - return json as PolarTokenResponse; -} /** * Register the user with the Polar app (`POST /v3/users`). Required once before @@ -192,66 +83,6 @@ export async function registerUser( } } -// ─── Collection reads ────────────────────────────────────────── - -/** One Polar Nightly Recharge record. */ -export interface PolarNightlyRecharge { - date: string; - nightly_recharge_status?: number | null; - ans_charge?: number | null; - ans_charge_status?: number | null; - heart_rate_avg?: number | null; - heart_rate_variability_avg?: number | null; - breathing_rate_avg?: number | null; -} - -/** One Polar Sleep record. Durations are seconds. */ -export interface PolarSleep { - date: string; - sleep_start_time?: string; - sleep_end_time?: string; - light_sleep?: number | null; - deep_sleep?: number | null; - rem_sleep?: number | null; - total_interruption_duration?: number | null; - sleep_charge?: number | null; - sleep_score?: number | null; - heart_rate_samples?: Record; -} - -/** One current AccessLink daily-activity summary. */ -export interface PolarActivity { - start_time?: string; - active_calories?: number | null; - steps?: number | null; - calories?: number | null; - /** Estimated walking/running distance derived from steps, in METRES. */ - distance_from_steps?: number | null; -} - -/** - * One Polar Training Load Pro record (cardio-load collection). `cardio_load` is - * the session/day cardiovascular load figure — Polar's WHOOP-day-strain - * analogue. `strain` / `tolerance` / `cardio_load_ratio` describe the - * acute-vs-chronic balance; only `cardio_load` is mapped today (the others are - * derived ratios we don't surface yet). - */ -export interface PolarCardioLoad { - date: string; - cardio_load?: number | null; - strain?: number | null; - tolerance?: number | null; - cardio_load_ratio?: number | null; -} - -/** One Polar SpO2 (Elixir pulse-ox) test result. `test_time` is a Unix - * timestamp in seconds; `blood_oxygen_percent` is the recorded percentage. */ -export interface PolarSpo2 { - source_device_id?: string; - test_time?: number; - blood_oxygen_percent?: number | null; -} - interface CollectionResult { records: T[]; } @@ -398,385 +229,6 @@ export function fetchSpo2(accessToken: string): Promise { ); } -// ─── Field → Measurement mapping ─────────────────────────────── - -const SEC_TO_MIN = 1 / 60; - -/** A single mapped reading destined for one `Measurement` row. The sync layer - * stamps `source = POLAR` + `externalId = ::` unless - * the mapper supplies its own full `externalId` (the per-segment sleep rows - * need an index in the key). */ -export interface MappedMeasurement { - type: string; - value: number; - unit: string; - measuredAt: Date; - fieldTag: string; - sleepStage?: "CORE" | "DEEP" | "REM" | "AWAKE" | "IN_BED"; - /** - * Full externalId for rows the mapper keys itself rather than letting the - * sync layer build `::`. The reconstructed sleep - * segments (one row per synthetic span) carry an index so the several rows of - * one stage stay distinct under `userId_type_source_externalId`. When set the - * sync layer uses it verbatim. - */ - externalId?: string; - /** - * The calendar date a date-keyed row belongs to (`YYYY-MM-DD`). The sync - * layer keys the externalId on it rather than on `measuredAt`, which sits at - * the date's local noon in the user's zone and so can fall on a different - * UTC date. - */ - day?: string; - /** - * `true` on per-segment sleep rows whose ORDER is synthesised. When Polar - * gives only per-stage duration totals (no per-stage onset timestamps), the - * timeline is reconstructed in a fixed physiological order; the UI labels such - * a night as an approximate layout and never presents it as measured timing. - */ - reconstructed?: boolean; -} - -/** A `nightly_recharge_status` is Polar's 1–6 recovery band; map it to a - * 0–100 RECOVERY_SCORE so it feeds the canonical recovery ladder below WHOOP. - * The status is the most stable recovery signal Polar exposes; we linearly - * rescale 1–6 → 0–100 (1 = worst, 6 = best). */ -function rechargeStatusToScore(status: number): number { - const clamped = Math.max(1, Math.min(6, status)); - return importValue(((clamped - 1) / 5) * 100); -} - -/** - * Map one Polar Nightly Recharge record. The recovery band → `RECOVERY_SCORE` - * (source POLAR, distinct from the COMPUTED proxy); HRV → `HRV_RMSSD`; the - * nightly average HR → `RESTING_HEART_RATE`; breathing rate → `RESPIRATORY_RATE`. - * `measuredAt` is the record's date anchored at local noon in the user's - * zone (the wake morning the night belongs to). - */ -export function mapNightlyRecharge( - r: PolarNightlyRecharge, - tz?: string, -): MappedMeasurement[] { - const measuredAt = canonicalDailyTimestamp(r.date, tz); - if (Number.isNaN(measuredAt.getTime())) return []; - const out: MappedMeasurement[] = []; - - if (typeof r.nightly_recharge_status === "number") { - out.push({ - type: "RECOVERY_SCORE", - value: rechargeStatusToScore(r.nightly_recharge_status), - unit: "score", - measuredAt, - fieldTag: "recovery", - }); - } - if ( - typeof r.heart_rate_variability_avg === "number" && - r.heart_rate_variability_avg > 0 - ) { - out.push({ - type: "HRV_RMSSD", - value: importValue(r.heart_rate_variability_avg), - unit: "ms", - measuredAt, - fieldTag: "hrv_rmssd", - }); - } - if (typeof r.heart_rate_avg === "number" && r.heart_rate_avg > 0) { - out.push({ - type: "RESTING_HEART_RATE", - value: Math.round(r.heart_rate_avg), - unit: "bpm", - measuredAt, - fieldTag: "rhr", - }); - } - if (typeof r.breathing_rate_avg === "number" && r.breathing_rate_avg > 0) { - out.push({ - type: "RESPIRATORY_RATE", - value: importValue(r.breathing_rate_avg), - unit: "breaths/min", - measuredAt, - fieldTag: "resp_rate", - }); - } - // ANS charge — the HRV-based autonomic-nervous-system component of Nightly - // Recharge, Polar's primary differentiated recovery signal. It is a deviation - // around the personal baseline (can be negative), so map it through as-is - // rather than clamping like the 1–6 recovery band. `0` is a valid reading - // (baseline), so guard on `number` only, not truthiness. - if (typeof r.ans_charge === "number") { - out.push({ - type: "ANS_CHARGE", - value: importValue(r.ans_charge), - unit: "score", - measuredAt, - fieldTag: "ans_charge", - }); - } - return out.map((m) => ({ ...m, day: r.date })); -} - -/** - * Map one Polar Sleep record into per-stage `SLEEP_DURATION` rows + the - * `SLEEP_SCORE` percentage. - * - * `measuredAt` follows the END-instant convention every other sleep integration - * uses (WHOOP/Withings/Fitbit) and that the canonical night-grouper - * (`analytics/sleep-night.ts`) depends on: each stage segment is stamped at its - * own END instant so the hypnogram has an ordered internal timeline and the - * wake-day key resolves to the right LOCAL day even for negative-UTC users. - * - * Polar's sleep collection carries only per-stage DURATION totals plus the - * night's `sleep_start_time` / `sleep_end_time` (ISO-8601 with local offset) — - * no per-stage onset timestamps. So we RECONSTRUCT an ordered, contiguous - * timeline exactly like WHOOP: lay the asleep stages back-to-back from - * `sleep_start_time` in a fixed physiological order (CORE → DEEP → REM), - * emitting one timed row per segment ending at the running cursor, and stamp the - * `IN_BED` envelope + the score row at `sleep_end_time`. The ORDER is synthetic, - * so every reconstructed segment is flagged `reconstructed: true` and keyed by - * a stage-tagged externalId so the several rows of one night stay distinct. - * - * If the night carries no usable start/end (older records, missing fields) we - * fall back to a midnight-UTC anchor and emit untimed stage rows — degraded but - * never wrong-day for the common UTC-positive case, matching the pre-fix shape. - */ -export function mapSleep(s: PolarSleep, tz?: string): MappedMeasurement[] { - const out: MappedMeasurement[] = []; - - const startMs = s.sleep_start_time ? Date.parse(s.sleep_start_time) : NaN; - const endMs = s.sleep_end_time ? Date.parse(s.sleep_end_time) : NaN; - const haveWindow = - Number.isFinite(startMs) && Number.isFinite(endMs) && endMs > startMs; - - const stages: Array< - [number | null | undefined, "CORE" | "DEEP" | "REM" | "AWAKE", string] - > = [ - // AWAKE first — Polar reports the night's total interruption time, but no - // per-stage onset. Lay it as a leading settling-in/awake block so the asleep - // stages partition the remaining window and the night reader can surface a - // real awakeMinutes / efficiency rather than reading the night as fully - // consolidated. Matches WHOOP's leading-AWAKE ordering. - [s.total_interruption_duration, "AWAKE", "sleep_awake"], - [s.light_sleep, "CORE", "sleep_core"], - [s.deep_sleep, "DEEP", "sleep_deep"], - [s.rem_sleep, "REM", "sleep_rem"], - ]; - - if (haveWindow) { - // Reconstructed timeline: lay each stage contiguously from onset, one timed - // row per segment ending at the running cursor. The order is synthetic - // (Polar gives no per-stage onset), so the shared builder flags every - // segment reconstructed; the same algorithm backs WHOOP's `mapSleep`. - out.push( - ...reconstructContiguousSleepTimeline({ - startMs, - stages: stages.map(([sec, stage, fieldTag]) => ({ - durationMs: - typeof sec === "number" && Number.isFinite(sec) ? sec * 1000 : sec, - stage, - fieldTag, - })), - // IN_BED — single envelope row over the whole sleep window, stamped at - // the sleep END so the in-bed reader resolves the span back to - // [start, end]. - inBed: { - durationMs: endMs - startMs, - measuredAt: new Date(endMs), - fieldTag: "sleep_in_bed", - }, - // Stage-tagged externalId keeps the several segment rows of one night - // distinct under userId_type_source_externalId (one segment per stage, - // no positional index — an index renumbered on a re-score when a - // stage's duration flipped 0↔positive, minting fresh ids that - // double-counted the night). - externalIdFor: (fieldTag) => `sleep:${s.date}:seg:${fieldTag}`, - }), - ); - - if (typeof s.sleep_score === "number") { - out.push({ - type: "SLEEP_PERFORMANCE", - value: importValue(s.sleep_score), - unit: "%", - measuredAt: new Date(endMs), - fieldTag: "sleep_score", - }); - } - return out; - } - - // Fallback: no usable window. Anchor at local noon of the wake date and - // emit untimed stage rows (the pre-v1.17.1 shape) so a record missing the - // window fields still contributes a night total. - const measuredAt = canonicalDailyTimestamp(s.date, tz); - if (Number.isNaN(measuredAt.getTime())) return []; - for (const [sec, stage, fieldTag] of stages) { - if (typeof sec === "number" && sec >= 0) { - out.push({ - type: "SLEEP_DURATION", - value: importValue(sec * SEC_TO_MIN), - unit: "minutes", - measuredAt, - fieldTag, - sleepStage: stage, - }); - } - } - if (typeof s.sleep_score === "number") { - out.push({ - type: "SLEEP_PERFORMANCE", - value: importValue(s.sleep_score), - unit: "%", - measuredAt, - fieldTag: "sleep_score", - }); - } - return out.map((m) => ({ ...m, day: s.date })); -} - -/** Map one current AccessLink daily-activity record: `steps` + - * `active_calories` where Polar reports them. The active energy portion is - * used instead of total `calories`, which includes BMR. */ -export function mapActivity( - a: PolarActivity, - tz?: string, -): MappedMeasurement[] { - if (typeof a.start_time !== "string") return []; - const day = a.start_time.slice(0, 10); - const measuredAt = canonicalDailyTimestamp(day, tz); - if (Number.isNaN(measuredAt.getTime())) return []; - const out: MappedMeasurement[] = []; - const steps = a.steps; - if (typeof steps === "number" && steps >= 0) { - out.push({ - type: "ACTIVITY_STEPS", - value: Math.round(steps), - unit: "steps", - measuredAt, - fieldTag: "steps", - }); - } - const activeCalories = a.active_calories; - if (typeof activeCalories === "number" && activeCalories >= 0) { - out.push({ - type: "ACTIVE_ENERGY_BURNED", - value: importValue(activeCalories), - unit: "kcal", - measuredAt, - fieldTag: "active_energy", - }); - } - // Distance derived from steps, already in METRES (canonical SI for - // WALKING_RUNNING_DISTANCE). Fills the POLAR slot the distance ladder already - // ranks — previously a phantom entry with no producer. - const distance = a.distance_from_steps; - if (typeof distance === "number" && distance >= 0) { - out.push({ - type: "WALKING_RUNNING_DISTANCE", - value: importValue(distance), - unit: "meters", - measuredAt, - fieldTag: "distance", - }); - } - return out.map((m) => ({ ...m, day })); -} - -/** Map one Polar Training Load Pro record: `cardio_load` → `CARDIO_LOAD` - * (Polar's device-native cardiovascular-strain figure). `0` is a valid load, so - * guard on `number` only. */ -export function mapCardioLoad( - c: PolarCardioLoad, - tz?: string, -): MappedMeasurement[] { - const measuredAt = canonicalDailyTimestamp(c.date, tz); - if (Number.isNaN(measuredAt.getTime())) return []; - if (typeof c.cardio_load !== "number" || c.cardio_load < 0) return []; - return [ - { - type: "CARDIO_LOAD", - value: importValue(c.cardio_load), - unit: "score", - measuredAt, - fieldTag: "cardio_load", - day: c.date, - }, - ]; -} - -/** Map one Polar SpO2 test result: `blood_oxygen_percent` → - * `OXYGEN_SATURATION` at the test's exact Unix timestamp. */ -export function mapSpo2(r: PolarSpo2): MappedMeasurement[] { - if (typeof r.test_time !== "number") return []; - const measuredAt = new Date(r.test_time * 1000); - if (Number.isNaN(measuredAt.getTime())) return []; - const pct = r.blood_oxygen_percent; - if (typeof pct !== "number" || pct <= 0 || pct > 100) return []; - return [ - { - type: "OXYGEN_SATURATION", - value: importValue(pct), - unit: "%", - measuredAt, - fieldTag: "spo2", - externalId: `spo2:${r.test_time}:${r.source_device_id ?? "unknown"}`, - }, - ]; -} - -// ─── Exercises (workouts) ────────────────────────────────────── - -/** - * One Polar AccessLink exercise (`GET /v3/exercises`) — the stateless list of - * the last 30 days of uploads. The transaction model is DEPRECATED; this GET - * carries NO userId in the path (the user is identified by the Bearer token), - * exactly the shape the v1.31.2 vitals-endpoint fix established. - * - * Field types are conservative on purpose — the live payload's exact per-field - * nullability and the `id` type are not machine-verified, so `id` accepts - * string OR number (coerced to a stable string externalId) and every optional - * numeric is guarded before use. - */ -export interface PolarExercise { - /** Opaque exercise id. Polar's docs leave the type ambiguous; accept either - * and coerce to a stable string externalId (never parsed). */ - id?: string | number | null; - /** ISO-8601 local start time, e.g. `"2026-06-10T07:00:00"`. May or may not - * carry a zone suffix — handled defensively at map time. */ - start_time?: string | null; - /** Minutes east of UTC for `start_time`. Combined with `start_time` to - * recover the true UTC instant. */ - start_time_utc_offset?: number | null; - /** ISO-8601 duration, e.g. `"PT2H44M30S"`. */ - duration?: string | null; - /** Coarse sport enum, e.g. `"RUNNING"`. */ - sport?: string | null; - /** Granular sport enum, e.g. `"TRAIL_RUNNING"`. */ - detailed_sport_info?: string | null; - calories?: number | null; - /** Distance in METRES. */ - distance?: number | null; - heart_rate?: { - average?: number | null; - maximum?: number | null; - } | null; - training_load?: number | null; - device?: string | null; - device_id?: string | null; -} - -/** - * Candidate envelope keys the `/v3/exercises` list might nest under. The - * verified shape (AccessLink swagger, 2026-07-24) is a BARE array covering the - * last 30 days OF UPLOADS, with NO pagination token or page parameter — so the - * bare-array branch is the live path. This tolerant key list is kept purely as - * defense against a future envelope rename; when it engages (an object body - * matching none of these keys), the fetch site logs a `polar.envelope.drift` - * breadcrumb rather than silently reading empty. - */ -const EXERCISE_LIST_KEYS = ["exercises", "data", "items", "records"] as const; - /** * Fetch the user's exercises (`GET /v3/exercises`). Summary only — the * `samples` / `zones` / `route` inline params are deliberately omitted (route / @@ -833,178 +285,3 @@ export async function fetchExercises( } return normaliseExerciseList(json); } - -/** Coerce a `/v3/exercises` body into a `PolarExercise[]`: a bare array, a - * paged envelope (list under one of `EXERCISE_LIST_KEYS`), or `[]` otherwise. */ -export function normaliseExerciseList(json: unknown): PolarExercise[] { - if (!json) return []; - if (Array.isArray(json)) return json as PolarExercise[]; - if (typeof json === "object") { - const obj = json as Record; - for (const key of EXERCISE_LIST_KEYS) { - const list = obj[key]; - if (Array.isArray(list)) return list as PolarExercise[]; - } - } - return []; -} - -// ─── Exercise → Workout mapping ──────────────────────────────── - -/** The `Workout`-row payload a mapped Polar exercise produces. Field names - * mirror `prisma.workout` columns; the sync layer stamps `source = POLAR` + - * `externalId` and upserts on `(userId, source, externalId)`. Mirrors - * `StravaWorkoutRow`. */ -export interface PolarWorkoutRow { - externalId: string; - sportType: WorkoutSportType; - startedAt: Date; - endedAt: Date; - durationSec: number; - totalEnergyKcal: number | null; - totalDistanceM: number | null; - avgHeartRate: number | null; - maxHeartRate: number | null; - elevationM: number | null; - metadata: Prisma.InputJsonValue; -} - -function nonNegInt(n: number | null | undefined): number | null { - if (typeof n !== "number" || !Number.isFinite(n) || n < 0) return null; - return Math.round(n); -} - -function nonNegFloat(n: number | null | undefined): number | null { - if (typeof n !== "number" || !Number.isFinite(n) || n < 0) return null; - return n; -} - -/** True when an ISO-8601 datetime string already carries a zone designator - * (`Z` or `±HH:MM` / `±HHMM`). Drives the two branches of start-time - * composition. */ -function hasZoneDesignator(iso: string): boolean { - return /(?:Z|[+-]\d{2}:?\d{2})$/.test(iso.trim()); -} - -/** - * Combine Polar's `start_time` + `start_time_utc_offset` into the true UTC - * instant. - * - * Two cases, because whether `start_time` carries a zone suffix is not verified - * against the live payload: - * - It already has a zone (`…Z` / `…+02:00`): `Date.parse` yields the correct - * instant directly; the offset is NOT applied again. - * - No zone (a naive local wall-clock, the documented shape): interpret the - * wall-clock AS UTC (append `Z`), then subtract the offset minutes (east of - * UTC is positive) to recover the true instant. A missing / non-finite - * offset falls back to 0 (treat the wall-clock as UTC). - * - * Returns null for a missing / unparseable start (the row is skipped, mirroring - * Strava's NaN-start guard). - */ -export function combinePolarStart( - startTime: string | null | undefined, - offsetMinutes: number | null | undefined, -): Date | null { - if (typeof startTime !== "string" || startTime.trim() === "") return null; - const trimmed = startTime.trim(); - if (hasZoneDesignator(trimmed)) { - const ms = Date.parse(trimmed); - return Number.isNaN(ms) ? null : new Date(ms); - } - const asUtcMs = Date.parse(`${trimmed}Z`); - if (Number.isNaN(asUtcMs)) return null; - const offset = - typeof offsetMinutes === "number" && Number.isFinite(offsetMinutes) - ? offsetMinutes - : 0; - return new Date(asUtcMs - offset * 60_000); -} - -const ISO_DURATION_RE = - /^P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/i; - -/** - * Parse an ISO-8601 duration (`PT2H44M30S`, `PT45S`, `P1DT2H`, fractional - * seconds allowed) to whole seconds. Malformed / empty input → 0, mirroring - * Strava's `durationSec ?? 0` fallback so a bad duration never produces a - * negative window. Hand-rolled — no new dependency. - */ -export function parseIsoDuration(raw: string | null | undefined): number { - if (typeof raw !== "string") return 0; - const m = ISO_DURATION_RE.exec(raw.trim()); - if (!m) return 0; - const [, days, hours, minutes, seconds] = m; - const total = - (days ? parseFloat(days) * 86_400 : 0) + - (hours ? parseFloat(hours) * 3_600 : 0) + - (minutes ? parseFloat(minutes) * 60 : 0) + - (seconds ? parseFloat(seconds) : 0); - if (!Number.isFinite(total) || total < 0) return 0; - return Math.round(total); -} - -/** - * Map one Polar exercise into a `Workout` row. `detailed_sport_info` is - * preferred over the coarse `sport`, then mapped through `mapPolarSportType()` - * to a canonical `WorkoutSportType` — the raw Polar labels are never written to - * `sportType` directly (kept in `metadata` for provenance). `startedAt` is the - * UTC instant recovered from `start_time` + `start_time_utc_offset`; `endedAt` - * is `startedAt + durationSec` (Polar exposes no explicit end). `training_load` - * rides `metadata` tied to this row (the WHOOP per-workout-strain precedent), - * never a free-floating Measurement that would survive read-time dedup. - * - * Returns null for an exercise with no id or no parseable start instant - * (nothing to store) — the sync layer skips it, never throws per-row. - */ -export function mapExercise(ex: PolarExercise): PolarWorkoutRow | null { - if (ex.id === null || ex.id === undefined) return null; - const externalId = String(ex.id); - if (externalId.trim() === "") return null; - - const startedAt = combinePolarStart(ex.start_time, ex.start_time_utc_offset); - if (!startedAt) return null; - - const durationSec = parseIsoDuration(ex.duration); - const endedAt = new Date(startedAt.getTime() + durationSec * 1000); - - const sportType = mapPolarSportType(ex.detailed_sport_info, ex.sport); - - const avgHeartRate = nonNegInt(ex.heart_rate?.average); - const maxHeartRate = nonNegInt(ex.heart_rate?.maximum); - - const metadata: Prisma.InputJsonValue = { - // Raw pre-mapping labels, kept for provenance/debugging — never used as the - // canonical `sportType` (see `mapPolarSportType()`). - ...(ex.sport ? { polarSport: ex.sport } : {}), - ...(ex.detailed_sport_info - ? { polarDetailedSportInfo: ex.detailed_sport_info } - : {}), - ...(ex.device ? { polarDevice: ex.device } : {}), - ...(ex.device_id ? { polarDeviceId: ex.device_id } : {}), - ...(typeof ex.training_load === "number" && - Number.isFinite(ex.training_load) - ? { trainingLoad: ex.training_load } - : {}), - ...(typeof ex.start_time_utc_offset === "number" && - Number.isFinite(ex.start_time_utc_offset) - ? { startTimeUtcOffset: ex.start_time_utc_offset } - : {}), - }; - - return { - externalId, - sportType, - startedAt, - endedAt, - durationSec, - totalEnergyKcal: nonNegFloat(ex.calories), - totalDistanceM: nonNegFloat(ex.distance), - avgHeartRate, - maxHeartRate, - // Not on the exercise summary — deriving it would require a GPX fetch - // (out of scope). Left null. - elevationM: null, - metadata, - }; -} diff --git a/src/lib/polar/credentials.ts b/src/lib/polar/credentials.ts index 5151a1889..2f0abe496 100644 --- a/src/lib/polar/credentials.ts +++ b/src/lib/polar/credentials.ts @@ -12,7 +12,7 @@ import { prisma } from "@/lib/db"; import { decrypt, encrypt } from "@/lib/crypto"; import { obtainAccessToken } from "@/lib/integrations/oauth-kit/token"; -import { getPolarCredentials, type PolarCredentials } from "./client"; +import { getPolarCredentials, type PolarCredentials } from "./oauth"; import { polarTokenDescriptor } from "./oauth-descriptor"; /** diff --git a/src/lib/polar/map.ts b/src/lib/polar/map.ts new file mode 100644 index 000000000..c6cf07303 --- /dev/null +++ b/src/lib/polar/map.ts @@ -0,0 +1,536 @@ +/** + * Polar records to measurement and workout rows: pure functions over the + * wire shapes (`./wire-types.ts`), with no I/O, plus the parsing of an + * exercise list body. `__tests__/map-fixtures.test.ts` pins what each makes of + * sanitised fixtures. + */ +import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz"; +import type { Prisma } from "@/generated/prisma/client"; +import type { WorkoutSportType } from "@/lib/validations/workout"; +import { mapPolarSportType } from "./sport-map"; +import { importValue } from "@/lib/integrations/import-value"; +import { reconstructContiguousSleepTimeline } from "@/lib/sleep/reconstruct-timeline"; +import type { + PolarNightlyRecharge, + PolarSleep, + PolarActivity, + PolarCardioLoad, + PolarSpo2, + PolarExercise, +} from "./wire-types"; + +const SEC_TO_MIN = 1 / 60; + +/** A single mapped reading destined for one `Measurement` row. The sync layer + * stamps `source = POLAR` + `externalId = ::` unless + * the mapper supplies its own full `externalId` (the per-segment sleep rows + * need an index in the key). */ +export interface MappedMeasurement { + type: string; + value: number; + unit: string; + measuredAt: Date; + fieldTag: string; + sleepStage?: "CORE" | "DEEP" | "REM" | "AWAKE" | "IN_BED"; + /** + * Full externalId for rows the mapper keys itself rather than letting the + * sync layer build `::`. The reconstructed sleep + * segments (one row per synthetic span) carry an index so the several rows of + * one stage stay distinct under `userId_type_source_externalId`. When set the + * sync layer uses it verbatim. + */ + externalId?: string; + /** + * The calendar date a date-keyed row belongs to (`YYYY-MM-DD`). The sync + * layer keys the externalId on it rather than on `measuredAt`, which sits at + * the date's local noon in the user's zone and so can fall on a different + * UTC date. + */ + day?: string; + /** + * `true` on per-segment sleep rows whose ORDER is synthesised. When Polar + * gives only per-stage duration totals (no per-stage onset timestamps), the + * timeline is reconstructed in a fixed physiological order; the UI labels such + * a night as an approximate layout and never presents it as measured timing. + */ + reconstructed?: boolean; +} + +/** A `nightly_recharge_status` is Polar's 1–6 recovery band; map it to a + * 0–100 RECOVERY_SCORE so it feeds the canonical recovery ladder below WHOOP. + * The status is the most stable recovery signal Polar exposes; we linearly + * rescale 1–6 → 0–100 (1 = worst, 6 = best). */ +function rechargeStatusToScore(status: number): number { + const clamped = Math.max(1, Math.min(6, status)); + return importValue(((clamped - 1) / 5) * 100); +} + +/** + * Map one Polar Nightly Recharge record. The recovery band → `RECOVERY_SCORE` + * (source POLAR, distinct from the COMPUTED proxy); HRV → `HRV_RMSSD`; the + * nightly average HR → `RESTING_HEART_RATE`; breathing rate → `RESPIRATORY_RATE`. + * `measuredAt` is the record's date anchored at local noon in the user's + * zone (the wake morning the night belongs to). + */ +export function mapNightlyRecharge( + r: PolarNightlyRecharge, + tz?: string, +): MappedMeasurement[] { + const measuredAt = canonicalDailyTimestamp(r.date, tz); + if (Number.isNaN(measuredAt.getTime())) return []; + const out: MappedMeasurement[] = []; + + if (typeof r.nightly_recharge_status === "number") { + out.push({ + type: "RECOVERY_SCORE", + value: rechargeStatusToScore(r.nightly_recharge_status), + unit: "score", + measuredAt, + fieldTag: "recovery", + }); + } + if ( + typeof r.heart_rate_variability_avg === "number" && + r.heart_rate_variability_avg > 0 + ) { + out.push({ + type: "HRV_RMSSD", + value: importValue(r.heart_rate_variability_avg), + unit: "ms", + measuredAt, + fieldTag: "hrv_rmssd", + }); + } + if (typeof r.heart_rate_avg === "number" && r.heart_rate_avg > 0) { + out.push({ + type: "RESTING_HEART_RATE", + value: Math.round(r.heart_rate_avg), + unit: "bpm", + measuredAt, + fieldTag: "rhr", + }); + } + if (typeof r.breathing_rate_avg === "number" && r.breathing_rate_avg > 0) { + out.push({ + type: "RESPIRATORY_RATE", + value: importValue(r.breathing_rate_avg), + unit: "breaths/min", + measuredAt, + fieldTag: "resp_rate", + }); + } + // ANS charge — the HRV-based autonomic-nervous-system component of Nightly + // Recharge, Polar's primary differentiated recovery signal. It is a deviation + // around the personal baseline (can be negative), so map it through as-is + // rather than clamping like the 1–6 recovery band. `0` is a valid reading + // (baseline), so guard on `number` only, not truthiness. + if (typeof r.ans_charge === "number") { + out.push({ + type: "ANS_CHARGE", + value: importValue(r.ans_charge), + unit: "score", + measuredAt, + fieldTag: "ans_charge", + }); + } + return out.map((m) => ({ ...m, day: r.date })); +} + +/** + * Map one Polar Sleep record into per-stage `SLEEP_DURATION` rows + the + * `SLEEP_SCORE` percentage. + * + * `measuredAt` follows the END-instant convention every other sleep integration + * uses (WHOOP/Withings/Fitbit) and that the canonical night-grouper + * (`analytics/sleep-night.ts`) depends on: each stage segment is stamped at its + * own END instant so the hypnogram has an ordered internal timeline and the + * wake-day key resolves to the right LOCAL day even for negative-UTC users. + * + * Polar's sleep collection carries only per-stage DURATION totals plus the + * night's `sleep_start_time` / `sleep_end_time` (ISO-8601 with local offset) — + * no per-stage onset timestamps. So we RECONSTRUCT an ordered, contiguous + * timeline exactly like WHOOP: lay the asleep stages back-to-back from + * `sleep_start_time` in a fixed physiological order (CORE → DEEP → REM), + * emitting one timed row per segment ending at the running cursor, and stamp the + * `IN_BED` envelope + the score row at `sleep_end_time`. The ORDER is synthetic, + * so every reconstructed segment is flagged `reconstructed: true` and keyed by + * a stage-tagged externalId so the several rows of one night stay distinct. + * + * If the night carries no usable start/end (older records, missing fields) we + * fall back to a midnight-UTC anchor and emit untimed stage rows — degraded but + * never wrong-day for the common UTC-positive case, matching the pre-fix shape. + */ +export function mapSleep(s: PolarSleep, tz?: string): MappedMeasurement[] { + const out: MappedMeasurement[] = []; + + const startMs = s.sleep_start_time ? Date.parse(s.sleep_start_time) : NaN; + const endMs = s.sleep_end_time ? Date.parse(s.sleep_end_time) : NaN; + const haveWindow = + Number.isFinite(startMs) && Number.isFinite(endMs) && endMs > startMs; + + const stages: Array< + [number | null | undefined, "CORE" | "DEEP" | "REM" | "AWAKE", string] + > = [ + // AWAKE first — Polar reports the night's total interruption time, but no + // per-stage onset. Lay it as a leading settling-in/awake block so the asleep + // stages partition the remaining window and the night reader can surface a + // real awakeMinutes / efficiency rather than reading the night as fully + // consolidated. Matches WHOOP's leading-AWAKE ordering. + [s.total_interruption_duration, "AWAKE", "sleep_awake"], + [s.light_sleep, "CORE", "sleep_core"], + [s.deep_sleep, "DEEP", "sleep_deep"], + [s.rem_sleep, "REM", "sleep_rem"], + ]; + + if (haveWindow) { + // Reconstructed timeline: lay each stage contiguously from onset, one timed + // row per segment ending at the running cursor. The order is synthetic + // (Polar gives no per-stage onset), so the shared builder flags every + // segment reconstructed; the same algorithm backs WHOOP's `mapSleep`. + out.push( + ...reconstructContiguousSleepTimeline({ + startMs, + stages: stages.map(([sec, stage, fieldTag]) => ({ + durationMs: + typeof sec === "number" && Number.isFinite(sec) ? sec * 1000 : sec, + stage, + fieldTag, + })), + // IN_BED — single envelope row over the whole sleep window, stamped at + // the sleep END so the in-bed reader resolves the span back to + // [start, end]. + inBed: { + durationMs: endMs - startMs, + measuredAt: new Date(endMs), + fieldTag: "sleep_in_bed", + }, + // Stage-tagged externalId keeps the several segment rows of one night + // distinct under userId_type_source_externalId (one segment per stage, + // no positional index — an index renumbered on a re-score when a + // stage's duration flipped 0↔positive, minting fresh ids that + // double-counted the night). + externalIdFor: (fieldTag) => `sleep:${s.date}:seg:${fieldTag}`, + }), + ); + + if (typeof s.sleep_score === "number") { + out.push({ + type: "SLEEP_PERFORMANCE", + value: importValue(s.sleep_score), + unit: "%", + measuredAt: new Date(endMs), + fieldTag: "sleep_score", + }); + } + return out; + } + + // Fallback: no usable window. Anchor at local noon of the wake date and + // emit untimed stage rows (the pre-v1.17.1 shape) so a record missing the + // window fields still contributes a night total. + const measuredAt = canonicalDailyTimestamp(s.date, tz); + if (Number.isNaN(measuredAt.getTime())) return []; + for (const [sec, stage, fieldTag] of stages) { + if (typeof sec === "number" && sec >= 0) { + out.push({ + type: "SLEEP_DURATION", + value: importValue(sec * SEC_TO_MIN), + unit: "minutes", + measuredAt, + fieldTag, + sleepStage: stage, + }); + } + } + if (typeof s.sleep_score === "number") { + out.push({ + type: "SLEEP_PERFORMANCE", + value: importValue(s.sleep_score), + unit: "%", + measuredAt, + fieldTag: "sleep_score", + }); + } + return out.map((m) => ({ ...m, day: s.date })); +} + +/** Map one current AccessLink daily-activity record: `steps` + + * `active_calories` where Polar reports them. The active energy portion is + * used instead of total `calories`, which includes BMR. */ +export function mapActivity( + a: PolarActivity, + tz?: string, +): MappedMeasurement[] { + if (typeof a.start_time !== "string") return []; + const day = a.start_time.slice(0, 10); + const measuredAt = canonicalDailyTimestamp(day, tz); + if (Number.isNaN(measuredAt.getTime())) return []; + const out: MappedMeasurement[] = []; + const steps = a.steps; + if (typeof steps === "number" && steps >= 0) { + out.push({ + type: "ACTIVITY_STEPS", + value: Math.round(steps), + unit: "steps", + measuredAt, + fieldTag: "steps", + }); + } + const activeCalories = a.active_calories; + if (typeof activeCalories === "number" && activeCalories >= 0) { + out.push({ + type: "ACTIVE_ENERGY_BURNED", + value: importValue(activeCalories), + unit: "kcal", + measuredAt, + fieldTag: "active_energy", + }); + } + // Distance derived from steps, already in METRES (canonical SI for + // WALKING_RUNNING_DISTANCE). Fills the POLAR slot the distance ladder already + // ranks — previously a phantom entry with no producer. + const distance = a.distance_from_steps; + if (typeof distance === "number" && distance >= 0) { + out.push({ + type: "WALKING_RUNNING_DISTANCE", + value: importValue(distance), + unit: "meters", + measuredAt, + fieldTag: "distance", + }); + } + return out.map((m) => ({ ...m, day })); +} + +/** Map one Polar Training Load Pro record: `cardio_load` → `CARDIO_LOAD` + * (Polar's device-native cardiovascular-strain figure). `0` is a valid load, so + * guard on `number` only. */ +export function mapCardioLoad( + c: PolarCardioLoad, + tz?: string, +): MappedMeasurement[] { + const measuredAt = canonicalDailyTimestamp(c.date, tz); + if (Number.isNaN(measuredAt.getTime())) return []; + if (typeof c.cardio_load !== "number" || c.cardio_load < 0) return []; + return [ + { + type: "CARDIO_LOAD", + value: importValue(c.cardio_load), + unit: "score", + measuredAt, + fieldTag: "cardio_load", + day: c.date, + }, + ]; +} + +/** Map one Polar SpO2 test result: `blood_oxygen_percent` → + * `OXYGEN_SATURATION` at the test's exact Unix timestamp. */ +export function mapSpo2(r: PolarSpo2): MappedMeasurement[] { + if (typeof r.test_time !== "number") return []; + const measuredAt = new Date(r.test_time * 1000); + if (Number.isNaN(measuredAt.getTime())) return []; + const pct = r.blood_oxygen_percent; + if (typeof pct !== "number" || pct <= 0 || pct > 100) return []; + return [ + { + type: "OXYGEN_SATURATION", + value: importValue(pct), + unit: "%", + measuredAt, + fieldTag: "spo2", + externalId: `spo2:${r.test_time}:${r.source_device_id ?? "unknown"}`, + }, + ]; +} + +/** + * Candidate envelope keys the `/v3/exercises` list might nest under. The + * verified shape (AccessLink swagger, 2026-07-24) is a BARE array covering the + * last 30 days OF UPLOADS, with NO pagination token or page parameter — so the + * bare-array branch is the live path. This tolerant key list is kept purely as + * defense against a future envelope rename; when it engages (an object body + * matching none of these keys), the fetch site logs a `polar.envelope.drift` + * breadcrumb rather than silently reading empty. + */ +export const EXERCISE_LIST_KEYS = [ + "exercises", + "data", + "items", + "records", +] as const; + +/** Coerce a `/v3/exercises` body into a `PolarExercise[]`: a bare array, a + * paged envelope (list under one of `EXERCISE_LIST_KEYS`), or `[]` otherwise. */ +export function normaliseExerciseList(json: unknown): PolarExercise[] { + if (!json) return []; + if (Array.isArray(json)) return json as PolarExercise[]; + if (typeof json === "object") { + const obj = json as Record; + for (const key of EXERCISE_LIST_KEYS) { + const list = obj[key]; + if (Array.isArray(list)) return list as PolarExercise[]; + } + } + return []; +} + +// ─── Exercise → Workout mapping ──────────────────────────────── + +/** The `Workout`-row payload a mapped Polar exercise produces. Field names + * mirror `prisma.workout` columns; the sync layer stamps `source = POLAR` + + * `externalId` and upserts on `(userId, source, externalId)`. Mirrors + * `StravaWorkoutRow`. */ +export interface PolarWorkoutRow { + externalId: string; + sportType: WorkoutSportType; + startedAt: Date; + endedAt: Date; + durationSec: number; + totalEnergyKcal: number | null; + totalDistanceM: number | null; + avgHeartRate: number | null; + maxHeartRate: number | null; + elevationM: number | null; + metadata: Prisma.InputJsonValue; +} + +function nonNegInt(n: number | null | undefined): number | null { + if (typeof n !== "number" || !Number.isFinite(n) || n < 0) return null; + return Math.round(n); +} + +function nonNegFloat(n: number | null | undefined): number | null { + if (typeof n !== "number" || !Number.isFinite(n) || n < 0) return null; + return n; +} + +/** True when an ISO-8601 datetime string already carries a zone designator + * (`Z` or `±HH:MM` / `±HHMM`). Drives the two branches of start-time + * composition. */ +function hasZoneDesignator(iso: string): boolean { + return /(?:Z|[+-]\d{2}:?\d{2})$/.test(iso.trim()); +} + +/** + * Combine Polar's `start_time` + `start_time_utc_offset` into the true UTC + * instant. + * + * Two cases, because whether `start_time` carries a zone suffix is not verified + * against the live payload: + * - It already has a zone (`…Z` / `…+02:00`): `Date.parse` yields the correct + * instant directly; the offset is NOT applied again. + * - No zone (a naive local wall-clock, the documented shape): interpret the + * wall-clock AS UTC (append `Z`), then subtract the offset minutes (east of + * UTC is positive) to recover the true instant. A missing / non-finite + * offset falls back to 0 (treat the wall-clock as UTC). + * + * Returns null for a missing / unparseable start (the row is skipped, mirroring + * Strava's NaN-start guard). + */ +export function combinePolarStart( + startTime: string | null | undefined, + offsetMinutes: number | null | undefined, +): Date | null { + if (typeof startTime !== "string" || startTime.trim() === "") return null; + const trimmed = startTime.trim(); + if (hasZoneDesignator(trimmed)) { + const ms = Date.parse(trimmed); + return Number.isNaN(ms) ? null : new Date(ms); + } + const asUtcMs = Date.parse(`${trimmed}Z`); + if (Number.isNaN(asUtcMs)) return null; + const offset = + typeof offsetMinutes === "number" && Number.isFinite(offsetMinutes) + ? offsetMinutes + : 0; + return new Date(asUtcMs - offset * 60_000); +} + +const ISO_DURATION_RE = + /^P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/i; + +/** + * Parse an ISO-8601 duration (`PT2H44M30S`, `PT45S`, `P1DT2H`, fractional + * seconds allowed) to whole seconds. Malformed / empty input → 0, mirroring + * Strava's `durationSec ?? 0` fallback so a bad duration never produces a + * negative window. Hand-rolled — no new dependency. + */ +export function parseIsoDuration(raw: string | null | undefined): number { + if (typeof raw !== "string") return 0; + const m = ISO_DURATION_RE.exec(raw.trim()); + if (!m) return 0; + const [, days, hours, minutes, seconds] = m; + const total = + (days ? parseFloat(days) * 86_400 : 0) + + (hours ? parseFloat(hours) * 3_600 : 0) + + (minutes ? parseFloat(minutes) * 60 : 0) + + (seconds ? parseFloat(seconds) : 0); + if (!Number.isFinite(total) || total < 0) return 0; + return Math.round(total); +} + +/** + * Map one Polar exercise into a `Workout` row. `detailed_sport_info` is + * preferred over the coarse `sport`, then mapped through `mapPolarSportType()` + * to a canonical `WorkoutSportType` — the raw Polar labels are never written to + * `sportType` directly (kept in `metadata` for provenance). `startedAt` is the + * UTC instant recovered from `start_time` + `start_time_utc_offset`; `endedAt` + * is `startedAt + durationSec` (Polar exposes no explicit end). `training_load` + * rides `metadata` tied to this row (the WHOOP per-workout-strain precedent), + * never a free-floating Measurement that would survive read-time dedup. + * + * Returns null for an exercise with no id or no parseable start instant + * (nothing to store) — the sync layer skips it, never throws per-row. + */ +export function mapExercise(ex: PolarExercise): PolarWorkoutRow | null { + if (ex.id === null || ex.id === undefined) return null; + const externalId = String(ex.id); + if (externalId.trim() === "") return null; + + const startedAt = combinePolarStart(ex.start_time, ex.start_time_utc_offset); + if (!startedAt) return null; + + const durationSec = parseIsoDuration(ex.duration); + const endedAt = new Date(startedAt.getTime() + durationSec * 1000); + + const sportType = mapPolarSportType(ex.detailed_sport_info, ex.sport); + + const avgHeartRate = nonNegInt(ex.heart_rate?.average); + const maxHeartRate = nonNegInt(ex.heart_rate?.maximum); + + const metadata: Prisma.InputJsonValue = { + // Raw pre-mapping labels, kept for provenance/debugging — never used as the + // canonical `sportType` (see `mapPolarSportType()`). + ...(ex.sport ? { polarSport: ex.sport } : {}), + ...(ex.detailed_sport_info + ? { polarDetailedSportInfo: ex.detailed_sport_info } + : {}), + ...(ex.device ? { polarDevice: ex.device } : {}), + ...(ex.device_id ? { polarDeviceId: ex.device_id } : {}), + ...(typeof ex.training_load === "number" && + Number.isFinite(ex.training_load) + ? { trainingLoad: ex.training_load } + : {}), + ...(typeof ex.start_time_utc_offset === "number" && + Number.isFinite(ex.start_time_utc_offset) + ? { startTimeUtcOffset: ex.start_time_utc_offset } + : {}), + }; + + return { + externalId, + sportType, + startedAt, + endedAt, + durationSec, + totalEnergyKcal: nonNegFloat(ex.calories), + totalDistanceM: nonNegFloat(ex.distance), + avgHeartRate, + maxHeartRate, + // Not on the exercise summary — deriving it would require a GPX fetch + // (out of scope). Left null. + elevationM: null, + metadata, + }; +} diff --git a/src/lib/polar/oauth.ts b/src/lib/polar/oauth.ts new file mode 100644 index 000000000..22ae3647a --- /dev/null +++ b/src/lib/polar/oauth.ts @@ -0,0 +1,64 @@ +/** + * The Polar AccessLink OAuth client: consent URL and code exchange, over the + * shared factory (`integrations/oauth-kit/client.ts`). + * + * The token endpoint takes HTTP Basic client authentication. Polar access + * tokens do not expire and there is no refresh token; a revoked grant shows + * as a refused read. The token answer carries `x_user_id`, Polar's member id, + * which user registration and direct user lookups still need. + */ +import { + defineOAuthClient, + type OAuthClientCredentials, +} from "@/lib/integrations/oauth-kit/client"; +import { derivedRedirectUri } from "@/lib/integrations/oauth-kit/redirect-uri"; + +import { PolarApiError } from "./response-classifier"; + +export type PolarCredentials = OAuthClientCredentials; + +/** + * `accesslink.read_all` grants every read collection so the person consents + * once. Space-separated on the wire. + */ +export const POLAR_OAUTH_SCOPE = "accesslink.read_all" as const; + +/** + * The env-configured shared OAuth credentials: the fallback the per-user + * resolver (`getPolarClientCredentials`) drops to when a user has not stored + * their own AccessLink app. Null when unconfigured, so the connect route can + * say the integration is not set up. + */ +export function getPolarCredentials(): PolarCredentials | null { + const clientId = process.env.POLAR_CLIENT_ID; + const clientSecret = process.env.POLAR_CLIENT_SECRET; + if (!clientId || !clientSecret) return null; + return { clientId, clientSecret }; +} + +export interface PolarTokenResponse { + access_token: string; + token_type: string; + expires_in?: number; + x_user_id: number; +} + +export const polarOAuth = defineOAuthClient({ + service: "polar", + authorizeUrl: "https://flow.polar.com/oauth2/authorization", + tokenUrl: "https://polarremote.com/v2/oauth2/token", + redirectUri: () => + derivedRedirectUri("POLAR_REDIRECT_URI", "/api/polar/callback"), + authorizeParams: () => ({ scope: POLAR_OAUTH_SCOPE }), + clientAuth: { kind: "basic", idInForm: { exchange: false, refresh: false } }, + acceptJson: true, + refresh: false, + error: (failure) => + new PolarApiError({ + verb: failure.verb, + classification: failure.classification, + httpStatus: failure.status, + reason: failure.reason, + upstreamError: failure.upstreamError, + }), +}); diff --git a/src/lib/polar/sync-workouts.ts b/src/lib/polar/sync-workouts.ts index 59e15cfec..e92035790 100644 --- a/src/lib/polar/sync-workouts.ts +++ b/src/lib/polar/sync-workouts.ts @@ -39,7 +39,8 @@ import { recordSyncSuccess, toFailureKind, } from "@/lib/integrations/status"; -import { fetchExercises, mapExercise, type PolarWorkoutRow } from "./client"; +import { fetchExercises } from "./client"; +import { mapExercise, type PolarWorkoutRow } from "./map"; import { getPolarConnection } from "./credentials"; import { PolarApiError, diff --git a/src/lib/polar/sync.ts b/src/lib/polar/sync.ts index 080108b79..10c6976e8 100644 --- a/src/lib/polar/sync.ts +++ b/src/lib/polar/sync.ts @@ -53,18 +53,11 @@ import { toFailureKind, type FailureKind, } from "@/lib/integrations/status"; -import { - collapseToTypeDayKeys, - recomputeBucketsForKeys, -} from "@/lib/rollups/measurement-rollups"; +import { afterProviderWrite } from "@/lib/integrations/sync-kit/after-write"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; -import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; import type { SyncWriteResult } from "@/lib/outcome/written-outcome"; import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; -import { - emitInsertedMeasurementArrivals, - type InsertedMeasurementArrivalRow, -} from "@/lib/arrivals/measurement-emit"; +import type { InsertedMeasurementArrivalRow } from "@/lib/arrivals/measurement-emit"; import { MeasurementReconciliationError, reconcileExternalMeasurement, @@ -75,13 +68,15 @@ import { fetchNightlyRecharges, fetchSleeps, fetchSpo2, +} from "./client"; +import { mapActivity, mapCardioLoad, mapNightlyRecharge, mapSleep, mapSpo2, type MappedMeasurement, -} from "./client"; +} from "./map"; import { sweepStaleSleepSegments, type SleepSegmentSweep, @@ -441,26 +436,12 @@ export async function upsertPolarMeasurements( } opts.onInserted?.(insertedRows); - void emitInsertedMeasurementArrivals(userId, insertedRows, "polar").catch( - () => {}, - ); - try { - const keys = collapseToTypeDayKeys(touched); - await recomputeBucketsForKeys(userId, keys); - invalidateStatusInsightsForTypes( - userId, - keys.map((k) => k.type), - { source: "sync" }, - ).catch((err) => { - getEvent()?.addWarning( - `polar: status-insight invalidate failed for ${userId}: ${err}`, - ); - }); - } catch (err) { - getEvent()?.addWarning( - `polar: rollup recompute failed for ${userId}: ${err}`, - ); - } + await afterProviderWrite({ + userId, + provider: "polar", + touched, + arrivals: { rows: insertedRows, source: "polar" }, + }); return imported; } diff --git a/src/lib/polar/wire-types.ts b/src/lib/polar/wire-types.ts new file mode 100644 index 000000000..860371375 --- /dev/null +++ b/src/lib/polar/wire-types.ts @@ -0,0 +1,103 @@ +/** + * The Polar AccessLink v3 wire shapes HealthLog reads, one interface per + * collection record. Shared by the endpoint fetchers (`./client.ts`) and the + * mappers (`./map.ts`). + */ +/** One Polar Nightly Recharge record. */ +export interface PolarNightlyRecharge { + date: string; + nightly_recharge_status?: number | null; + ans_charge?: number | null; + ans_charge_status?: number | null; + heart_rate_avg?: number | null; + heart_rate_variability_avg?: number | null; + breathing_rate_avg?: number | null; +} + +/** One Polar Sleep record. Durations are seconds. */ +export interface PolarSleep { + date: string; + sleep_start_time?: string; + sleep_end_time?: string; + light_sleep?: number | null; + deep_sleep?: number | null; + rem_sleep?: number | null; + total_interruption_duration?: number | null; + sleep_charge?: number | null; + sleep_score?: number | null; + heart_rate_samples?: Record; +} + +/** One current AccessLink daily-activity summary. */ +export interface PolarActivity { + start_time?: string; + active_calories?: number | null; + steps?: number | null; + calories?: number | null; + /** Estimated walking/running distance derived from steps, in METRES. */ + distance_from_steps?: number | null; +} + +/** + * One Polar Training Load Pro record (cardio-load collection). `cardio_load` is + * the session/day cardiovascular load figure — Polar's WHOOP-day-strain + * analogue. `strain` / `tolerance` / `cardio_load_ratio` describe the + * acute-vs-chronic balance; only `cardio_load` is mapped today (the others are + * derived ratios we don't surface yet). + */ +export interface PolarCardioLoad { + date: string; + cardio_load?: number | null; + strain?: number | null; + tolerance?: number | null; + cardio_load_ratio?: number | null; +} + +/** One Polar SpO2 (Elixir pulse-ox) test result. `test_time` is a Unix + * timestamp in seconds; `blood_oxygen_percent` is the recorded percentage. */ +export interface PolarSpo2 { + source_device_id?: string; + test_time?: number; + blood_oxygen_percent?: number | null; +} + +// ─── Exercises (workouts) ────────────────────────────────────── + +/** + * One Polar AccessLink exercise (`GET /v3/exercises`) — the stateless list of + * the last 30 days of uploads. The transaction model is DEPRECATED; this GET + * carries NO userId in the path (the user is identified by the Bearer token), + * exactly the shape the v1.31.2 vitals-endpoint fix established. + * + * Field types are conservative on purpose — the live payload's exact per-field + * nullability and the `id` type are not machine-verified, so `id` accepts + * string OR number (coerced to a stable string externalId) and every optional + * numeric is guarded before use. + */ +export interface PolarExercise { + /** Opaque exercise id. Polar's docs leave the type ambiguous; accept either + * and coerce to a stable string externalId (never parsed). */ + id?: string | number | null; + /** ISO-8601 local start time, e.g. `"2026-06-10T07:00:00"`. May or may not + * carry a zone suffix — handled defensively at map time. */ + start_time?: string | null; + /** Minutes east of UTC for `start_time`. Combined with `start_time` to + * recover the true UTC instant. */ + start_time_utc_offset?: number | null; + /** ISO-8601 duration, e.g. `"PT2H44M30S"`. */ + duration?: string | null; + /** Coarse sport enum, e.g. `"RUNNING"`. */ + sport?: string | null; + /** Granular sport enum, e.g. `"TRAIL_RUNNING"`. */ + detailed_sport_info?: string | null; + calories?: number | null; + /** Distance in METRES. */ + distance?: number | null; + heart_rate?: { + average?: number | null; + maximum?: number | null; + } | null; + training_load?: number | null; + device?: string | null; + device_id?: string | null; +} diff --git a/src/lib/rollups/__tests__/after-measurement-mutation.test.ts b/src/lib/rollups/__tests__/after-measurement-mutation.test.ts index cf80d3cb6..17fd3ca91 100644 --- a/src/lib/rollups/__tests__/after-measurement-mutation.test.ts +++ b/src/lib/rollups/__tests__/after-measurement-mutation.test.ts @@ -93,7 +93,9 @@ describe("afterMeasurementMutation", () => { }); it("is a no-op for an empty row set", async () => { - await afterMeasurementMutation("user-1", [], { source: "manual" }); + await expect( + afterMeasurementMutation("user-1", [], { source: "manual" }), + ).resolves.toEqual({ rollupFailed: false }); expect(mocks.recompute).not.toHaveBeenCalled(); expect(mocks.invalidate).not.toHaveBeenCalled(); }); @@ -101,8 +103,9 @@ describe("afterMeasurementMutation", () => { it("still invalidates status insights when the rollup recompute fails", async () => { mocks.recompute.mockRejectedValue(new Error("populator down")); const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + let result: { rollupFailed: boolean } | undefined; try { - await afterMeasurementMutation( + result = await afterMeasurementMutation( "user-1", [ { @@ -115,10 +118,29 @@ describe("afterMeasurementMutation", () => { } finally { warn.mockRestore(); } - // The rollup failure never bubbles AND never gates the second leg. + // The rollup failure never bubbles AND never gates the second leg; the + // caller learns of it from the result. + expect(result).toEqual({ rollupFailed: true }); expect(mocks.invalidate).toHaveBeenCalledTimes(1); }); + it("leaves the status cards alone when the caller invalidates them itself", async () => { + await expect( + afterMeasurementMutation( + "user-1", + [ + { + type: "WEIGHT" as MeasurementType, + measuredAt: day("2026-05-10T08:00:00Z"), + }, + ], + { source: "sync", invalidateStatus: false }, + ), + ).resolves.toEqual({ rollupFailed: false }); + expect(mocks.recompute).toHaveBeenCalledTimes(1); + expect(mocks.invalidate).not.toHaveBeenCalled(); + }); + it("never rejects when the status-insight invalidation fails", async () => { mocks.invalidate.mockRejectedValue(new Error("cache down")); const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); @@ -134,7 +156,7 @@ describe("afterMeasurementMutation", () => { ], { source: "manual" }, ), - ).resolves.toBeUndefined(); + ).resolves.toEqual({ rollupFailed: false }); // Let the fire-and-forget rejection settle inside the helper's catch. await new Promise((resolve) => setImmediate(resolve)); } finally { diff --git a/src/lib/rollups/after-measurement-mutation.ts b/src/lib/rollups/after-measurement-mutation.ts index c96bbecdf..6cd1f834e 100644 --- a/src/lib/rollups/after-measurement-mutation.ts +++ b/src/lib/rollups/after-measurement-mutation.ts @@ -53,17 +53,24 @@ export async function afterMeasurementMutation( source: StatusIngestSource; /** Log prefix identifying the calling surface in the warn lines. */ label?: string; + /** + * Re-warm the status cards of the touched types. A caller that writes in + * chunks and invalidates once at the end passes false. + */ + invalidateStatus?: boolean; }, -): Promise { +): Promise<{ rollupFailed: boolean }> { const label = options.label ?? "measurements"; - if (rows.length === 0) return; + if (rows.length === 0) return { rollupFailed: false }; // Collapse to the distinct `(type, day)` set BEFORE recomputing so a // 500-row batch fires at most one DAY recompute per touched bucket. const keys = collapseToTypeDayKeys(rows); + let rollupFailed = false; try { await recomputeBucketsForKeys(userId, keys); } catch (err) { + rollupFailed = true; console.warn(`[${label}] rollup recompute failed`, err); } invalidateUserMeasurements(userId, { touchedFrom: earliestMeasuredAt(rows) }); @@ -71,7 +78,7 @@ export async function afterMeasurementMutation( // Status-insight re-warm is fire-and-forget — it must run even when // the rollup leg above failed, so the two legs never gate each other. const types = Array.from(new Set(keys.map((k) => k.type))); - if (types.length > 0) { + if (types.length > 0 && options.invalidateStatus !== false) { // Registered so the test suite can await it: the warn below is a console // write on a detached handle, and one that lands while a vitest worker is // closing its RPC channel fails the whole run. No-op outside test. @@ -83,4 +90,5 @@ export async function afterMeasurementMutation( }), ); } + return { rollupFailed }; } diff --git a/src/lib/strava/__tests__/__fixtures__/activities.json b/src/lib/strava/__tests__/__fixtures__/activities.json new file mode 100644 index 000000000..8a5085846 --- /dev/null +++ b/src/lib/strava/__tests__/__fixtures__/activities.json @@ -0,0 +1,166 @@ +[ + { + "label": "run with heart rate, power and a detail read", + "summary": { + "id": 10000000001, + "name": "Morning Run", + "distance": 10234.5, + "moving_time": 3120, + "elapsed_time": 3300, + "total_elevation_gain": 85.2, + "type": "Run", + "sport_type": "TrailRun", + "start_date": "2026-03-02T06:00:00Z", + "start_date_local": "2026-03-02T07:00:00Z", + "average_heartrate": 151.6, + "max_heartrate": 178.2, + "has_heartrate": true, + "average_watts": 245.3, + "weighted_average_watts": 252, + "device_watts": true, + "average_cadence": 86.5, + "trainer": false, + "commute": false, + "gear_id": "gear-0001" + }, + "detail": { + "id": 10000000001, + "start_date": "2026-03-02T06:00:00Z", + "calories": 712.4, + "description": "Easy loop around the park" + } + }, + { + "label": "summary-only ride without heart rate", + "summary": { + "id": 10000000002, + "name": "Lunch Ride", + "distance": 32500, + "moving_time": 4500, + "elapsed_time": 5100, + "total_elevation_gain": 210, + "type": "Ride", + "sport_type": "Ride", + "start_date": "2026-03-03T11:30:00Z", + "has_heartrate": false, + "average_watts": 180.5, + "device_watts": false, + "trainer": true, + "commute": true + } + }, + { + "label": "summary without a start, filled from the detail", + "summary": { + "id": 10000000003, + "name": "Afternoon Walk", + "sport_type": "Walk", + "start_date": null + }, + "detail": { + "id": 10000000003, + "start_date": "2026-03-04T15:00:00+01:00", + "distance": 4200, + "moving_time": 2700, + "elapsed_time": 3000, + "total_elevation_gain": 12, + "average_heartrate": 98.4, + "max_heartrate": 121.5, + "calories": 0, + "description": "" + } + }, + { + "label": "legacy type only", + "summary": { + "id": 10000000004, + "name": "Weekend Hike", + "type": "Hike", + "sport_type": null, + "start_date": "2026-03-07T08:00:00Z", + "moving_time": 10800, + "elapsed_time": null, + "distance": 14000.25 + }, + "detail": null + }, + { + "label": "unknown sport, moving time only", + "summary": { + "id": 10000000005, + "sport_type": "SomethingStravaAddedLater", + "start_date": "2026-03-08T18:00:00-05:00", + "moving_time": 1800 + } + }, + { + "label": "no sport label anywhere", + "summary": { + "id": 10000000006, + "start_date": "2026-03-09T07:00:00Z", + "elapsed_time": 1500 + } + }, + { + "label": "zero and negative values", + "summary": { + "id": 10000000007, + "name": "", + "distance": 0, + "moving_time": -10, + "elapsed_time": 0, + "total_elevation_gain": -3.5, + "sport_type": "Virtual Ride", + "start_date": "2026-03-10T19:00:00Z", + "average_heartrate": -1, + "max_heartrate": 0, + "gear_id": "" + }, + "detail": { + "id": 10000000007, + "calories": -12, + "sport_type": "VirtualRide" + } + }, + { + "label": "detail sport type used when the summary has none", + "summary": { + "id": 10000000008, + "start_date": "2026-03-11T05:45:00Z", + "moving_time": 2400, + "elapsed_time": 2460 + }, + "detail": { + "id": 10000000008, + "sport_type": "weight_training", + "average_heartrate": 112, + "calories": 250.5 + } + }, + { + "label": "no start anywhere", + "summary": { + "id": 10000000009, + "sport_type": "Swim", + "moving_time": 1800 + } + }, + { + "label": "unparseable start", + "summary": { + "id": 10000000010, + "sport_type": "Swim", + "start_date": "yesterday morning", + "moving_time": 1800 + } + }, + { + "label": "no durations at all", + "summary": { + "id": 10000000011, + "sport_type": "Yoga", + "start_date": "2026-03-12T20:00:00Z", + "has_heartrate": null + } + } +] diff --git a/src/lib/strava/__tests__/__golden__/map-activities.json b/src/lib/strava/__tests__/__golden__/map-activities.json new file mode 100644 index 000000000..38857de21 --- /dev/null +++ b/src/lib/strava/__tests__/__golden__/map-activities.json @@ -0,0 +1,190 @@ +[ + { + "label": "run with heart rate, power and a detail read", + "summaryHasHeartRate": true, + "row": { + "externalId": "10000000001", + "sportType": "running", + "startedAt": "2026-03-02T06:00:00.000Z", + "endedAt": "2026-03-02T06:55:00.000Z", + "durationSec": 3120, + "totalEnergyKcal": 712.4, + "totalDistanceM": 10234.5, + "avgHeartRate": 152, + "maxHeartRate": 178, + "elevationM": 85.2, + "metadata": { + "stravaName": "Morning Run", + "stravaDescription": "Easy loop around the park", + "elapsedTimeSec": 3300, + "stravaType": "TrailRun", + "averageWatts": 245.3, + "weightedAverageWatts": 252, + "deviceWatts": true, + "averageCadence": 86.5, + "trainer": false, + "commute": false, + "gearId": "gear-0001" + }, + "summaryOnly": false + } + }, + { + "label": "summary-only ride without heart rate", + "summaryHasHeartRate": false, + "row": { + "externalId": "10000000002", + "sportType": "cycling", + "startedAt": "2026-03-03T11:30:00.000Z", + "endedAt": "2026-03-03T12:55:00.000Z", + "durationSec": 4500, + "totalEnergyKcal": null, + "totalDistanceM": 32500, + "avgHeartRate": null, + "maxHeartRate": null, + "elevationM": 210, + "metadata": { + "stravaName": "Lunch Ride", + "elapsedTimeSec": 5100, + "stravaType": "Ride", + "averageWatts": 180.5, + "deviceWatts": false, + "trainer": true, + "commute": true + }, + "summaryOnly": true + } + }, + { + "label": "summary without a start, filled from the detail", + "summaryHasHeartRate": false, + "row": { + "externalId": "10000000003", + "sportType": "walking", + "startedAt": "2026-03-04T14:00:00.000Z", + "endedAt": "2026-03-04T14:50:00.000Z", + "durationSec": 2700, + "totalEnergyKcal": 0, + "totalDistanceM": 4200, + "avgHeartRate": 98, + "maxHeartRate": 122, + "elevationM": 12, + "metadata": { + "stravaName": "Afternoon Walk", + "elapsedTimeSec": 3000, + "stravaType": "Walk" + }, + "summaryOnly": false + } + }, + { + "label": "legacy type only", + "summaryHasHeartRate": false, + "row": { + "externalId": "10000000004", + "sportType": "hiking", + "startedAt": "2026-03-07T08:00:00.000Z", + "endedAt": "2026-03-07T11:00:00.000Z", + "durationSec": 10800, + "totalEnergyKcal": null, + "totalDistanceM": 14000.25, + "avgHeartRate": null, + "maxHeartRate": null, + "elevationM": null, + "metadata": { "stravaName": "Weekend Hike", "stravaType": "Hike" }, + "summaryOnly": true + } + }, + { + "label": "unknown sport, moving time only", + "summaryHasHeartRate": false, + "row": { + "externalId": "10000000005", + "sportType": "other", + "startedAt": "2026-03-08T23:00:00.000Z", + "endedAt": "2026-03-08T23:30:00.000Z", + "durationSec": 1800, + "totalEnergyKcal": null, + "totalDistanceM": null, + "avgHeartRate": null, + "maxHeartRate": null, + "elevationM": null, + "metadata": { "stravaType": "SomethingStravaAddedLater" }, + "summaryOnly": true + } + }, + { + "label": "no sport label anywhere", + "summaryHasHeartRate": false, + "row": { + "externalId": "10000000006", + "sportType": "other", + "startedAt": "2026-03-09T07:00:00.000Z", + "endedAt": "2026-03-09T07:25:00.000Z", + "durationSec": 1500, + "totalEnergyKcal": null, + "totalDistanceM": null, + "avgHeartRate": null, + "maxHeartRate": null, + "elevationM": null, + "metadata": { "elapsedTimeSec": 1500, "stravaType": "Workout" }, + "summaryOnly": true + } + }, + { + "label": "zero and negative values", + "summaryHasHeartRate": true, + "row": { + "externalId": "10000000007", + "sportType": "cycling", + "startedAt": "2026-03-10T19:00:00.000Z", + "endedAt": "2026-03-10T19:00:00.000Z", + "durationSec": 0, + "totalEnergyKcal": null, + "totalDistanceM": 0, + "avgHeartRate": null, + "maxHeartRate": 0, + "elevationM": null, + "metadata": { "elapsedTimeSec": 0, "stravaType": "Virtual Ride" }, + "summaryOnly": false + } + }, + { + "label": "detail sport type used when the summary has none", + "summaryHasHeartRate": false, + "row": { + "externalId": "10000000008", + "sportType": "strength", + "startedAt": "2026-03-11T05:45:00.000Z", + "endedAt": "2026-03-11T06:26:00.000Z", + "durationSec": 2400, + "totalEnergyKcal": 250.5, + "totalDistanceM": null, + "avgHeartRate": 112, + "maxHeartRate": null, + "elevationM": null, + "metadata": { "elapsedTimeSec": 2460, "stravaType": "weight_training" }, + "summaryOnly": false + } + }, + { "label": "no start anywhere", "summaryHasHeartRate": false, "row": null }, + { "label": "unparseable start", "summaryHasHeartRate": false, "row": null }, + { + "label": "no durations at all", + "summaryHasHeartRate": false, + "row": { + "externalId": "10000000011", + "sportType": "yoga", + "startedAt": "2026-03-12T20:00:00.000Z", + "endedAt": "2026-03-12T20:00:00.000Z", + "durationSec": 0, + "totalEnergyKcal": null, + "totalDistanceM": null, + "avgHeartRate": null, + "maxHeartRate": null, + "elevationM": null, + "metadata": { "stravaType": "Yoga" }, + "summaryOnly": true + } + } +] diff --git a/src/lib/strava/__tests__/__golden__/map-rate-limit.json b/src/lib/strava/__tests__/__golden__/map-rate-limit.json new file mode 100644 index 000000000..a8295ae99 --- /dev/null +++ b/src/lib/strava/__tests__/__golden__/map-rate-limit.json @@ -0,0 +1,12 @@ +[ + { "label": "no headers", "seconds": 450 }, + { "label": "retry-after seconds", "seconds": 120 }, + { "label": "retry-after HTTP date", "seconds": 150 }, + { "label": "retry-after zero falls through", "seconds": 450 }, + { "label": "15-minute window spent", "seconds": 450 }, + { "label": "daily window spent", "seconds": 49950 }, + { "label": "daily read window spent", "seconds": 49950 }, + { "label": "malformed pair", "seconds": 450 }, + { "label": "exactly on a quarter hour", "seconds": 900 }, + { "label": "half a second before midnight, daily spent", "seconds": 1 } +] diff --git a/src/lib/strava/__tests__/__golden__/map-sport.json b/src/lib/strava/__tests__/__golden__/map-sport.json new file mode 100644 index 000000000..51fa47053 --- /dev/null +++ b/src/lib/strava/__tests__/__golden__/map-sport.json @@ -0,0 +1,16 @@ +[ + { "raw": "Run", "sportType": "running" }, + { "raw": "TrailRun", "sportType": "running" }, + { "raw": "VirtualRide", "sportType": "cycling" }, + { "raw": "Virtual Ride", "sportType": "cycling" }, + { "raw": "virtual_ride", "sportType": "cycling" }, + { "raw": " WeightTraining ", "sportType": "strength" }, + { "raw": "HighIntensityIntervalTraining", "sportType": "hiit" }, + { "raw": "Snowshoe", "sportType": "hiking" }, + { "raw": "Workout", "sportType": "other" }, + { "raw": "SomethingStravaAddedLater", "sportType": "other" }, + { "raw": "", "sportType": "other" }, + { "raw": " ", "sportType": "other" }, + { "raw": null, "sportType": "other" }, + { "raw": "(undefined)", "sportType": "other" } +] diff --git a/src/lib/strava/__tests__/client.test.ts b/src/lib/strava/__tests__/client.test.ts index a6a470720..e9c944d3f 100644 --- a/src/lib/strava/__tests__/client.test.ts +++ b/src/lib/strava/__tests__/client.test.ts @@ -1,11 +1,10 @@ import { describe, expect, it } from "vitest"; +import { mapActivity, summaryHasHeartRate } from "@/lib/strava/map"; import { - mapActivity, - summaryHasHeartRate, type StravaSummaryActivity, type StravaDetailedActivity, -} from "../client"; +} from "@/lib/strava/wire-types"; import { classifyStravaResponse } from "../response-classifier"; const baseSummary: StravaSummaryActivity = { diff --git a/src/lib/strava/__tests__/map-fixtures.test.ts b/src/lib/strava/__tests__/map-fixtures.test.ts new file mode 100644 index 000000000..b5624ef29 --- /dev/null +++ b/src/lib/strava/__tests__/map-fixtures.test.ts @@ -0,0 +1,159 @@ +/** + * What each Strava mapper makes of a fixed set of payloads, pinned in + * `__golden__/`. + * + * The fixtures under `__fixtures__/` are synthetic `SummaryActivity` + * payloads in the documented v3 wire shape, each paired with the + * `DetailedActivity` the sync would read for it (or none): heart rate on the + * summary or only on the detail, a start only on the detail, legacy `type` + * labels, unknown sports, zero and negative values, and the records the + * mapper refuses. The rate-limit wait and the sport lookup take scalar + * inputs, so their cases are tables in this file. The goldens were written + * while the mappers still lived in `client.ts`, so a green run after they + * move proves the move changed nothing. + */ +import { format } from "prettier"; +import { describe, expect, it } from "vitest"; + +import { stravaRateLimitRetryAfterSeconds } from "../client"; +import { mapActivity, summaryHasHeartRate } from "@/lib/strava/map"; +import { + type StravaDetailedActivity, + type StravaSummaryActivity, +} from "@/lib/strava/wire-types"; +import { mapStravaSportType } from "../sport-map"; +import activitiesFixture from "./__fixtures__/activities.json"; + +interface ActivityCase { + label: string; + summary: StravaSummaryActivity; + detail?: StravaDetailedActivity | null; +} + +const activities: readonly ActivityCase[] = activitiesFixture; + +/** The golden is written in the repository's JSON format, so the format check passes on it unchanged. */ +async function golden(value: unknown): Promise { + return format(JSON.stringify(value), { parser: "json" }); +} + +describe("Strava mappers against the fixture payloads", () => { + it("maps activities", async () => { + const output = activities.map(({ label, summary, detail }) => ({ + label, + summaryHasHeartRate: summaryHasHeartRate(summary), + row: mapActivity(summary, detail), + })); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-activities.json", + ); + }); + + it("resolves sport labels", async () => { + const labels: Array = [ + "Run", + "TrailRun", + "VirtualRide", + "Virtual Ride", + "virtual_ride", + " WeightTraining ", + "HighIntensityIntervalTraining", + "Snowshoe", + "Workout", + "SomethingStravaAddedLater", + "", + " ", + null, + undefined, + ]; + const output = labels.map((raw) => ({ + raw: raw === undefined ? "(undefined)" : raw, + sportType: mapStravaSportType(raw), + })); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-sport.json", + ); + }); + + it("derives the wait after a rate limit", async () => { + const cases: Array<{ + label: string; + headers: Record; + now: string; + }> = [ + { label: "no headers", headers: {}, now: "2026-03-02T10:07:30.000Z" }, + { + label: "retry-after seconds", + headers: { "retry-after": "120" }, + now: "2026-03-02T10:07:30.000Z", + }, + { + label: "retry-after HTTP date", + headers: { "retry-after": "Mon, 02 Mar 2026 10:10:00 GMT" }, + now: "2026-03-02T10:07:30.000Z", + }, + { + label: "retry-after zero falls through", + headers: { "retry-after": "0" }, + now: "2026-03-02T10:07:30.000Z", + }, + { + label: "15-minute window spent", + headers: { + "x-ratelimit-limit": "200,2000", + "x-ratelimit-usage": "200,450", + }, + now: "2026-03-02T10:07:30.000Z", + }, + { + label: "daily window spent", + headers: { + "x-ratelimit-limit": "200,2000", + "x-ratelimit-usage": "120,2000", + }, + now: "2026-03-02T10:07:30.000Z", + }, + { + label: "daily read window spent", + headers: { + "x-ratelimit-limit": "200,2000", + "x-ratelimit-usage": "40,500", + "x-readratelimit-limit": "100,1000", + "x-readratelimit-usage": "30,1001", + }, + now: "2026-03-02T10:07:30.000Z", + }, + { + label: "malformed pair", + headers: { + "x-ratelimit-limit": "200", + "x-ratelimit-usage": "abc,2000", + }, + now: "2026-03-02T10:07:30.000Z", + }, + { + label: "exactly on a quarter hour", + headers: {}, + now: "2026-03-02T10:15:00.000Z", + }, + { + label: "half a second before midnight, daily spent", + headers: { + "x-ratelimit-limit": "200,2000", + "x-ratelimit-usage": "10,2500", + }, + now: "2026-03-02T23:59:59.500Z", + }, + ]; + const output = cases.map(({ label, headers, now }) => ({ + label, + seconds: stravaRateLimitRetryAfterSeconds( + new Headers(headers), + new Date(now), + ), + })); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-rate-limit.json", + ); + }); +}); diff --git a/src/lib/strava/__tests__/token-refusal.test.ts b/src/lib/strava/__tests__/token-refusal.test.ts index 207f99f4a..e4d79e536 100644 --- a/src/lib/strava/__tests__/token-refusal.test.ts +++ b/src/lib/strava/__tests__/token-refusal.test.ts @@ -15,13 +15,13 @@ vi.mock("@/lib/safe-fetch", () => ({ safeFetch: vi.fn() })); import { safeFetch } from "@/lib/safe-fetch"; -import { exchangeCode, refreshAccessToken } from "../client"; import { classifyStravaError, stravaFaultSummary, StravaApiError, } from "../response-classifier"; import { classifyStravaFailure } from "../sync"; +import { stravaOAuth } from "../oauth"; const creds = { clientId: "12345", clientSecret: "not-a-real-secret" }; @@ -65,7 +65,7 @@ beforeEach(() => { describe("a refused Strava token request", () => { it("asks for a reconnect when the refresh token is dead", async () => { answer(400, DEAD_REFRESH_TOKEN); - const err = await refusal(() => refreshAccessToken("rt-dead", creds)); + const err = await refusal(() => stravaOAuth.refresh("rt-dead", creds)); expect(err.httpStatus).toBe(400); expect(err.upstreamError).toBe("RefreshToken.refresh_token:invalid"); @@ -75,7 +75,7 @@ describe("a refused Strava token request", () => { it("still asks for a reconnect when the error lost its prototype on a retry", async () => { answer(400, DEAD_REFRESH_TOKEN); - const err = await refusal(() => refreshAccessToken("rt-dead", creds)); + const err = await refusal(() => stravaOAuth.refresh("rt-dead", creds)); const replayed = new Error(err.message); expect(replayed.message).toBe( @@ -89,13 +89,13 @@ describe("a refused Strava token request", () => { // answers a per-person condition this way is not documented, so the // status decides, as it always has: 400 persistent, 401 reconnect. answer(400, BAD_CLIENT_ID); - const bad = await refusal(() => refreshAccessToken("rt-live", creds)); + const bad = await refusal(() => stravaOAuth.refresh("rt-live", creds)); expect(bad.upstreamError).toBe("Application.client_id:invalid"); expect(classifyStravaError(bad)).toBe("persistent"); answer(401, BAD_CLIENT); const unauthorised = await refusal(() => - refreshAccessToken("rt-live", creds), + stravaOAuth.refresh("rt-live", creds), ); expect(unauthorised.upstreamError).toBe("Application:invalid"); expect(classifyStravaError(unauthorised)).toBe("reauth_required"); @@ -103,18 +103,18 @@ describe("a refused Strava token request", () => { it("leaves a 400 that names neither as persistent and a 5xx as transient", async () => { answer(400, { message: "Bad Request", errors: [] }); - const plain = await refusal(() => exchangeCode("code", creds)); + const plain = await refusal(() => stravaOAuth.exchange("code", creds)); expect(plain.upstreamError).toBe("Bad Request"); expect(classifyStravaError(plain)).toBe("persistent"); answer(503, { message: "Service Unavailable" }); - const outage = await refusal(() => refreshAccessToken("rt-live", creds)); + const outage = await refusal(() => stravaOAuth.refresh("rt-live", creds)); expect(classifyStravaError(outage)).toBe("transient"); }); it("still honours a standards-shaped invalid_grant", async () => { answer(400, { error: "invalid_grant" }); - const err = await refusal(() => refreshAccessToken("rt-dead", creds)); + const err = await refusal(() => stravaOAuth.refresh("rt-dead", creds)); expect(err.upstreamError).toBe("invalid_grant"); expect(classifyStravaError(err)).toBe("reauth_required"); }); diff --git a/src/lib/strava/client.ts b/src/lib/strava/client.ts index 8b74d09d3..cc5ed7249 100644 --- a/src/lib/strava/client.ts +++ b/src/lib/strava/client.ts @@ -1,11 +1,10 @@ /** - * v1.28.x — Strava API v3 client for OAuth + activity fetching. + * Strava API v3 transport and endpoints: activity reads. * Docs: https://developers.strava.com/docs/reference/ (re-verify at build). * - * Mirrors the WHOOP / Polar / Oura client structure: hand-rolled fetch over - * `safeFetch` (no vendor SDK), an OAuth handshake (`getAuthorizationUrl` / - * `exchangeCode` / `refreshAccessToken`), typed collection fetchers, and a - * single field → `Workout` mapper. + * Hand-rolled fetch over `safeFetch` (no vendor SDK). The wire shapes are + * `./wire-types.ts`, the activity → workout mapping is `./map.ts`, the OAuth + * handshake is `./oauth.ts`. * * Strava specifics: * - It is a WORKOUT source only — the API is activity-centric and exposes no @@ -24,209 +23,15 @@ import { getEvent } from "@/lib/logging/context"; import { safeFetch } from "@/lib/safe-fetch"; import { parseRetryAfterMs } from "@/lib/integrations/oauth-kit/retry-after"; -import type { Prisma } from "@/generated/prisma/client"; -import { - StravaApiError, - classifyStravaResponse, - stravaFaultSummary, -} from "./response-classifier"; -import { mapStravaSportType } from "./sport-map"; -import type { WorkoutSportType } from "@/lib/validations/workout"; -import { envOr, envValue } from "@/lib/env"; + +import { StravaApiError, classifyStravaResponse } from "./response-classifier"; + +import type { + StravaSummaryActivity, + StravaDetailedActivity, +} from "./wire-types"; const STRAVA_API_BASE = "https://www.strava.com/api/v3"; -const STRAVA_OAUTH_AUTH_URL = "https://www.strava.com/oauth/authorize"; -const STRAVA_OAUTH_TOKEN_URL = "https://www.strava.com/oauth/token"; -const STRAVA_DEAUTHORIZE_URL = "https://www.strava.com/oauth/deauthorize"; - -export interface StravaCredentials { - clientId: string; - clientSecret: string; -} - -/** - * Env-configured shared OAuth credentials — the FALLBACK app used when a user - * has not registered their own BYO Strava client id/secret. The per-user - * resolver `getStravaClientCredentials` (DB-first) is the primary path and - * calls this only when no per-user pair is stored. - */ -export function getStravaCredentials(): StravaCredentials | null { - const clientId = process.env.STRAVA_CLIENT_ID; - const clientSecret = process.env.STRAVA_CLIENT_SECRET; - if (!clientId || !clientSecret) return null; - return { clientId, clientSecret }; -} - -export function getStravaRedirectUri(): string { - // An empty or blank value counts as unset: the compose whitelist - // materialises an unset var as an empty string, which must still fall - // through to the derived URI. - return envOr( - "STRAVA_REDIRECT_URI", - `${envValue("NEXT_PUBLIC_APP_URL")}/api/strava/callback`, - ); -} - -/** - * Strava OAuth scope. `activity:read_all` reads every activity including those - * marked "only me" (`activity:read` omits private ones). Comma-separated on the - * wire per Strava's authorization endpoint. - */ -export const STRAVA_OAUTH_SCOPE = "activity:read_all" as const; - -export function getAuthorizationUrl( - state: string, - creds: StravaCredentials, -): string { - const params = new URLSearchParams({ - client_id: creds.clientId, - response_type: "code", - redirect_uri: getStravaRedirectUri(), - approval_prompt: "auto", - scope: STRAVA_OAUTH_SCOPE, - state, - }); - return `${STRAVA_OAUTH_AUTH_URL}?${params}`; -} - -/** Strava's `SummaryAthlete` — only the numeric id is retained (the granted - * account pin + webhook fan-out key). */ -export interface StravaAthlete { - id?: number | null; -} - -export interface StravaTokenResponse { - access_token: string; - refresh_token: string; - expires_at?: number; - expires_in?: number; - token_type?: string; - athlete?: StravaAthlete | null; -} - -async function postToken( - params: URLSearchParams, - verb: string, -): Promise { - const start = performance.now(); - const res = await safeFetch(STRAVA_OAUTH_TOKEN_URL, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: params.toString(), - }); - const json = await res.json().catch(() => null); - const verdict = classifyStravaResponse(res.status); - getEvent()?.addExternalCall({ - service: "strava", - method: verb, - duration_ms: Math.round(performance.now() - start), - status: res.status, - error: verdict.classification === "success" ? undefined : verdict.reason, - }); - if (verdict.classification !== "success") { - throw new StravaApiError({ - verb, - classification: verdict.classification, - httpStatus: verdict.httpStatus, - reason: verdict.reason, - // Strava's `Fault`, reduced to the resource it names; the classifier - // tells a dead refresh token from a misconfigured app by it. - upstreamError: stravaFaultSummary(json), - }); - } - return json as StravaTokenResponse; -} - -export async function exchangeCode( - code: string, - creds: StravaCredentials, -): Promise { - return postToken( - new URLSearchParams({ - client_id: creds.clientId, - client_secret: creds.clientSecret, - code, - grant_type: "authorization_code", - }), - "exchangeCode", - ); -} - -export async function refreshAccessToken( - refreshToken: string, - creds: StravaCredentials, -): Promise { - return postToken( - new URLSearchParams({ - client_id: creds.clientId, - client_secret: creds.clientSecret, - grant_type: "refresh_token", - refresh_token: refreshToken, - }), - "refreshAccessToken", - ); -} - -/** - * Best-effort deauthorize at Strava on disconnect. Failure is swallowed by the - * caller — the local grant is cleared regardless, so a Strava-side outage never - * strands the user with a card that refuses to disconnect. - */ -export async function deauthorize(accessToken: string): Promise { - const start = performance.now(); - const res = await safeFetch(STRAVA_DEAUTHORIZE_URL, { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: "application/json", - }, - }); - getEvent()?.addExternalCall({ - service: "strava", - method: "deauthorize", - duration_ms: Math.round(performance.now() - start), - status: res.status, - error: res.ok ? undefined : `http_${res.status}`, - }); -} - -// ─── Activity reads (v3) ─────────────────────────────────────── - -/** Strava `SummaryActivity` — the list-endpoint shape. Distances are metres, - * durations seconds, `start_date` a UTC ISO-8601 instant. HR / power fields are - * present when the activity recorded them; `calories` is NOT (detail only). */ -export interface StravaSummaryActivity { - id: number; - name?: string | null; - distance?: number | null; - moving_time?: number | null; - elapsed_time?: number | null; - total_elevation_gain?: number | null; - type?: string | null; - sport_type?: string | null; - start_date?: string | null; - start_date_local?: string | null; - average_heartrate?: number | null; - max_heartrate?: number | null; - has_heartrate?: boolean | null; - average_watts?: number | null; - weighted_average_watts?: number | null; - device_watts?: boolean | null; - average_cadence?: number | null; - trainer?: boolean | null; - commute?: boolean | null; - gear_id?: string | null; -} - -/** Strava `DetailedActivity` — the per-activity shape. Superset of the summary - * that additionally carries `calories` (kcal). */ -export interface StravaDetailedActivity extends StravaSummaryActivity { - calories?: number | null; - description?: string | null; -} export interface FetchActivitiesQuery { /** Epoch SECONDS cursor — return activities started after this instant. */ @@ -346,145 +151,3 @@ export function fetchActivityById( "fetchActivityById", ); } - -// ─── Activity → Workout mapping ──────────────────────────────── - -/** The `Workout`-row payload a mapped Strava activity produces. Field names - * mirror `prisma.workout` columns; the sync layer stamps `source = STRAVA` + - * `externalId` and upserts on `(userId, source, externalId)`. */ -export interface StravaWorkoutRow { - externalId: string; - sportType: WorkoutSportType; - startedAt: Date; - endedAt: Date; - durationSec: number; - totalEnergyKcal: number | null; - totalDistanceM: number | null; - avgHeartRate: number | null; - maxHeartRate: number | null; - elevationM: number | null; - metadata: Prisma.InputJsonValue; - /** - * True when the row was mapped from the summary alone. The calories, the - * description and (when the summary lacks it) the heart rate exist only on - * the detail read, so an update from such a row must not write their - * absence over what an earlier detail read stored. - */ - summaryOnly?: boolean; -} - -function nonNegInt(n: number | null | undefined): number | null { - if (typeof n !== "number" || !Number.isFinite(n) || n < 0) return null; - return Math.round(n); -} - -function nonNegFloat(n: number | null | undefined): number | null { - if (typeof n !== "number" || !Number.isFinite(n) || n < 0) return null; - return n; -} - -/** - * Map one Strava activity into a `Workout` row. `sport_type` is preferred over - * the legacy `type`, then mapped through `mapStravaSportType()` to a canonical - * `WorkoutSportType` — the raw Strava label is never written to `sportType` - * directly (see `src/lib/strava/sport-map.ts` for why). The raw label is kept - * verbatim in `metadata.stravaType` for provenance/debugging. Duration uses - * `moving_time` (active duration) with `elapsed_time` kept in `metadata`. HR / - * calories come from the summary when present, else from the optional - * `DetailedActivity`. - * - * The activity `name` / `description` are the user's own free-text DATA — they - * are stored in `metadata` only and NEVER interpolated into any prompt as an - * instruction (the MCP/coach free-text rule). Power / cadence / commute flags - * ride `metadata` for provenance but are NOT surfaced as training analytics. - * Returns null for an activity with no id / start instant (nothing to store). - */ -export function mapActivity( - summary: StravaSummaryActivity, - detail?: StravaDetailedActivity | null, -): StravaWorkoutRow | null { - if (typeof summary.id !== "number") return null; - const startIso = summary.start_date ?? detail?.start_date ?? null; - if (!startIso) return null; - const startedAt = new Date(startIso); - if (Number.isNaN(startedAt.getTime())) return null; - - const elapsedSec = nonNegInt(summary.elapsed_time ?? detail?.elapsed_time); - const movingSec = nonNegInt(summary.moving_time ?? detail?.moving_time); - const durationSec = movingSec ?? elapsedSec ?? 0; - // The clock window is elapsed time (moving time excludes pauses); fall back to - // moving time when elapsed is absent so `endedAt` is never before `startedAt`. - const endedAt = new Date( - startedAt.getTime() + (elapsedSec ?? durationSec) * 1000, - ); - - const avgHeartRate = nonNegInt( - summary.average_heartrate ?? detail?.average_heartrate, - ); - const maxHeartRate = nonNegInt( - summary.max_heartrate ?? detail?.max_heartrate, - ); - const calories = nonNegFloat(detail?.calories); - - const rawSportType = - summary.sport_type ?? summary.type ?? detail?.sport_type ?? "Workout"; - const sportType = mapStravaSportType(rawSportType); - - const metadata: Prisma.InputJsonValue = { - // Activity name/description are user free-text → DATA, never instructions. - ...(summary.name ? { stravaName: summary.name } : {}), - ...(detail?.description ? { stravaDescription: detail.description } : {}), - ...(elapsedSec != null ? { elapsedTimeSec: elapsedSec } : {}), - // Raw pre-mapping label, kept for provenance/debugging — never used as - // the canonical `sportType` itself (see `mapStravaSportType()`). - ...(rawSportType ? { stravaType: rawSportType } : {}), - ...(typeof summary.average_watts === "number" - ? { averageWatts: summary.average_watts } - : {}), - ...(typeof summary.weighted_average_watts === "number" - ? { weightedAverageWatts: summary.weighted_average_watts } - : {}), - ...(typeof summary.device_watts === "boolean" - ? { deviceWatts: summary.device_watts } - : {}), - ...(typeof summary.average_cadence === "number" - ? { averageCadence: summary.average_cadence } - : {}), - ...(typeof summary.trainer === "boolean" - ? { trainer: summary.trainer } - : {}), - ...(typeof summary.commute === "boolean" - ? { commute: summary.commute } - : {}), - ...(summary.gear_id ? { gearId: summary.gear_id } : {}), - }; - - return { - externalId: String(summary.id), - sportType, - startedAt, - endedAt, - durationSec, - totalEnergyKcal: calories, - totalDistanceM: nonNegFloat(summary.distance ?? detail?.distance), - avgHeartRate, - maxHeartRate, - elevationM: nonNegFloat( - summary.total_elevation_gain ?? detail?.total_elevation_gain, - ), - metadata, - summaryOnly: detail == null, - }; -} - -/** True when the summary already carries HR + calories are irrelevant — i.e. - * the summary alone is enough and a detail fetch can be skipped. Strava never - * puts `calories` on the summary, so a detail fetch is what fills it; callers - * use this only to decide whether HR is already covered. */ -export function summaryHasHeartRate(a: StravaSummaryActivity): boolean { - return ( - a.has_heartrate === true || - typeof a.average_heartrate === "number" || - typeof a.max_heartrate === "number" - ); -} diff --git a/src/lib/strava/credentials.ts b/src/lib/strava/credentials.ts index 4410aac16..f0c03aa4e 100644 --- a/src/lib/strava/credentials.ts +++ b/src/lib/strava/credentials.ts @@ -12,7 +12,7 @@ */ import { prisma } from "@/lib/db"; import { decrypt, encrypt } from "@/lib/crypto"; -import { getStravaCredentials, type StravaCredentials } from "./client"; +import { getStravaCredentials, type StravaCredentials } from "./oauth"; /** * Resolve the user's Strava OAuth client id/secret, DB-first then env. A diff --git a/src/lib/strava/map.ts b/src/lib/strava/map.ts new file mode 100644 index 000000000..6ef96a36a --- /dev/null +++ b/src/lib/strava/map.ts @@ -0,0 +1,152 @@ +/** + * Strava activities to workout rows: pure functions over the wire shapes + * (`./wire-types.ts`), with no I/O. `__tests__/map-fixtures.test.ts` pins + * what they make of sanitised fixtures. + */ +import type { Prisma } from "@/generated/prisma/client"; +import { mapStravaSportType } from "./sport-map"; +import type { WorkoutSportType } from "@/lib/validations/workout"; +import type { + StravaSummaryActivity, + StravaDetailedActivity, +} from "./wire-types"; + +/** The `Workout`-row payload a mapped Strava activity produces. Field names + * mirror `prisma.workout` columns; the sync layer stamps `source = STRAVA` + + * `externalId` and upserts on `(userId, source, externalId)`. */ +export interface StravaWorkoutRow { + externalId: string; + sportType: WorkoutSportType; + startedAt: Date; + endedAt: Date; + durationSec: number; + totalEnergyKcal: number | null; + totalDistanceM: number | null; + avgHeartRate: number | null; + maxHeartRate: number | null; + elevationM: number | null; + metadata: Prisma.InputJsonValue; + /** + * True when the row was mapped from the summary alone. The calories, the + * description and (when the summary lacks it) the heart rate exist only on + * the detail read, so an update from such a row must not write their + * absence over what an earlier detail read stored. + */ + summaryOnly?: boolean; +} + +function nonNegInt(n: number | null | undefined): number | null { + if (typeof n !== "number" || !Number.isFinite(n) || n < 0) return null; + return Math.round(n); +} + +function nonNegFloat(n: number | null | undefined): number | null { + if (typeof n !== "number" || !Number.isFinite(n) || n < 0) return null; + return n; +} + +/** + * Map one Strava activity into a `Workout` row. `sport_type` is preferred over + * the legacy `type`, then mapped through `mapStravaSportType()` to a canonical + * `WorkoutSportType` — the raw Strava label is never written to `sportType` + * directly (see `src/lib/strava/sport-map.ts` for why). The raw label is kept + * verbatim in `metadata.stravaType` for provenance/debugging. Duration uses + * `moving_time` (active duration) with `elapsed_time` kept in `metadata`. HR / + * calories come from the summary when present, else from the optional + * `DetailedActivity`. + * + * The activity `name` / `description` are the user's own free-text DATA — they + * are stored in `metadata` only and NEVER interpolated into any prompt as an + * instruction (the MCP/coach free-text rule). Power / cadence / commute flags + * ride `metadata` for provenance but are NOT surfaced as training analytics. + * Returns null for an activity with no id / start instant (nothing to store). + */ +export function mapActivity( + summary: StravaSummaryActivity, + detail?: StravaDetailedActivity | null, +): StravaWorkoutRow | null { + if (typeof summary.id !== "number") return null; + const startIso = summary.start_date ?? detail?.start_date ?? null; + if (!startIso) return null; + const startedAt = new Date(startIso); + if (Number.isNaN(startedAt.getTime())) return null; + + const elapsedSec = nonNegInt(summary.elapsed_time ?? detail?.elapsed_time); + const movingSec = nonNegInt(summary.moving_time ?? detail?.moving_time); + const durationSec = movingSec ?? elapsedSec ?? 0; + // The clock window is elapsed time (moving time excludes pauses); fall back to + // moving time when elapsed is absent so `endedAt` is never before `startedAt`. + const endedAt = new Date( + startedAt.getTime() + (elapsedSec ?? durationSec) * 1000, + ); + + const avgHeartRate = nonNegInt( + summary.average_heartrate ?? detail?.average_heartrate, + ); + const maxHeartRate = nonNegInt( + summary.max_heartrate ?? detail?.max_heartrate, + ); + const calories = nonNegFloat(detail?.calories); + + const rawSportType = + summary.sport_type ?? summary.type ?? detail?.sport_type ?? "Workout"; + const sportType = mapStravaSportType(rawSportType); + + const metadata: Prisma.InputJsonValue = { + // Activity name/description are user free-text → DATA, never instructions. + ...(summary.name ? { stravaName: summary.name } : {}), + ...(detail?.description ? { stravaDescription: detail.description } : {}), + ...(elapsedSec != null ? { elapsedTimeSec: elapsedSec } : {}), + // Raw pre-mapping label, kept for provenance/debugging — never used as + // the canonical `sportType` itself (see `mapStravaSportType()`). + ...(rawSportType ? { stravaType: rawSportType } : {}), + ...(typeof summary.average_watts === "number" + ? { averageWatts: summary.average_watts } + : {}), + ...(typeof summary.weighted_average_watts === "number" + ? { weightedAverageWatts: summary.weighted_average_watts } + : {}), + ...(typeof summary.device_watts === "boolean" + ? { deviceWatts: summary.device_watts } + : {}), + ...(typeof summary.average_cadence === "number" + ? { averageCadence: summary.average_cadence } + : {}), + ...(typeof summary.trainer === "boolean" + ? { trainer: summary.trainer } + : {}), + ...(typeof summary.commute === "boolean" + ? { commute: summary.commute } + : {}), + ...(summary.gear_id ? { gearId: summary.gear_id } : {}), + }; + + return { + externalId: String(summary.id), + sportType, + startedAt, + endedAt, + durationSec, + totalEnergyKcal: calories, + totalDistanceM: nonNegFloat(summary.distance ?? detail?.distance), + avgHeartRate, + maxHeartRate, + elevationM: nonNegFloat( + summary.total_elevation_gain ?? detail?.total_elevation_gain, + ), + metadata, + summaryOnly: detail == null, + }; +} + +/** True when the summary already carries HR + calories are irrelevant — i.e. + * the summary alone is enough and a detail fetch can be skipped. Strava never + * puts `calories` on the summary, so a detail fetch is what fills it; callers + * use this only to decide whether HR is already covered. */ +export function summaryHasHeartRate(a: StravaSummaryActivity): boolean { + return ( + a.has_heartrate === true || + typeof a.average_heartrate === "number" || + typeof a.max_heartrate === "number" + ); +} diff --git a/src/lib/strava/oauth-descriptor.ts b/src/lib/strava/oauth-descriptor.ts index 52e9c1aa9..3bd28801f 100644 --- a/src/lib/strava/oauth-descriptor.ts +++ b/src/lib/strava/oauth-descriptor.ts @@ -9,7 +9,7 @@ import { obtainAccessToken, type OAuthTokenDescriptor, } from "@/lib/integrations/oauth-kit/token"; -import { refreshAccessToken, type StravaCredentials } from "./client"; +import { type StravaCredentials, stravaOAuth } from "./oauth"; import { getStravaClientCredentials } from "./credentials"; export const stravaTokenDescriptor: OAuthTokenDescriptor = { @@ -45,7 +45,7 @@ export const stravaTokenDescriptor: OAuthTokenDescriptor = { }, loadCredentials: (userId) => getStravaClientCredentials(userId), refresh: (refreshToken, credentials) => - refreshAccessToken(refreshToken, credentials), + stravaOAuth.refresh(refreshToken, credentials), }; /** diff --git a/src/lib/strava/oauth.ts b/src/lib/strava/oauth.ts new file mode 100644 index 000000000..756ea843d --- /dev/null +++ b/src/lib/strava/oauth.ts @@ -0,0 +1,79 @@ +/** + * The Strava OAuth client: consent URL, code exchange, refresh and + * deauthorization, over the shared factory + * (`integrations/oauth-kit/client.ts`). + * + * Strava rotates its refresh token on every refresh; the token lifecycle + * persists both. Its token endpoint does not answer in RFC 6749 form: a + * refusal is the API's generic `Fault`, reduced by `stravaFaultSummary` so the + * classifier can tell a dead refresh token (`RefreshToken.refresh_token:…`) + * from a misconfigured app. The exchange carries no `redirect_uri`. + */ +import { + defineOAuthClient, + type OAuthClientCredentials, +} from "@/lib/integrations/oauth-kit/client"; +import { derivedRedirectUri } from "@/lib/integrations/oauth-kit/redirect-uri"; + +import { StravaApiError, stravaFaultSummary } from "./response-classifier"; + +export type StravaCredentials = OAuthClientCredentials; + +/** + * `activity:read_all` reads every activity including those marked "only me" + * (`activity:read` omits private ones). Comma-separated on the wire. + */ +export const STRAVA_OAUTH_SCOPE = "activity:read_all" as const; + +/** Strava's `SummaryAthlete`: only the numeric id is kept (the account pin). */ +export interface StravaAthlete { + id?: number | null; +} + +export interface StravaTokenResponse { + access_token: string; + refresh_token: string; + expires_at?: number; + expires_in?: number; + token_type?: string; + athlete?: StravaAthlete | null; +} + +export const stravaOAuth = defineOAuthClient({ + service: "strava", + authorizeUrl: "https://www.strava.com/oauth/authorize", + tokenUrl: "https://www.strava.com/oauth/token", + deauthorizeUrl: "https://www.strava.com/oauth/deauthorize", + redirectUri: () => + derivedRedirectUri("STRAVA_REDIRECT_URI", "/api/strava/callback"), + authorizeParams: () => ({ + approval_prompt: "auto", + scope: STRAVA_OAUTH_SCOPE, + }), + clientAuth: { kind: "form" }, + acceptJson: true, + exchangeRedirectUri: false, + refresh: true, + upstreamError: stravaFaultSummary, + error: (failure) => + new StravaApiError({ + verb: failure.verb, + classification: failure.classification, + httpStatus: failure.status, + reason: failure.reason, + upstreamError: failure.upstreamError, + }), +}); + +/** + * Env-configured shared OAuth credentials — the FALLBACK app used when a user + * has not registered their own BYO Strava client id/secret. The per-user + * resolver `getStravaClientCredentials` (DB-first) is the primary path and + * calls this only when no per-user pair is stored. + */ +export function getStravaCredentials(): StravaCredentials | null { + const clientId = process.env.STRAVA_CLIENT_ID; + const clientSecret = process.env.STRAVA_CLIENT_SECRET; + if (!clientId || !clientSecret) return null; + return { clientId, clientSecret }; +} diff --git a/src/lib/strava/sync.ts b/src/lib/strava/sync.ts index 4d3560304..090b0d494 100644 --- a/src/lib/strava/sync.ts +++ b/src/lib/strava/sync.ts @@ -41,14 +41,9 @@ import { type FailureKind, } from "@/lib/integrations/status"; import { walkPages } from "@/lib/integrations/page-walk"; -import { - fetchActivities, - fetchActivityById, - mapActivity, - summaryHasHeartRate, - type StravaDetailedActivity, - type StravaWorkoutRow, -} from "./client"; +import { fetchActivities, fetchActivityById } from "./client"; +import { mapActivity, summaryHasHeartRate, type StravaWorkoutRow } from "./map"; +import { type StravaDetailedActivity } from "./wire-types"; import { getStravaConnection } from "./credentials"; import { refreshStravaAfterRefusal } from "./oauth-descriptor"; import { diff --git a/src/lib/strava/wire-types.ts b/src/lib/strava/wire-types.ts new file mode 100644 index 000000000..1f1cce0c9 --- /dev/null +++ b/src/lib/strava/wire-types.ts @@ -0,0 +1,37 @@ +/** + * The Strava API v3 wire shapes HealthLog reads: the list endpoint's + * `SummaryActivity` and the per-activity `DetailedActivity`. Shared by the + * endpoints (`./client.ts`) and the mapper (`./map.ts`). + */ +/** Strava `SummaryActivity` — the list-endpoint shape. Distances are metres, + * durations seconds, `start_date` a UTC ISO-8601 instant. HR / power fields are + * present when the activity recorded them; `calories` is NOT (detail only). */ +export interface StravaSummaryActivity { + id: number; + name?: string | null; + distance?: number | null; + moving_time?: number | null; + elapsed_time?: number | null; + total_elevation_gain?: number | null; + type?: string | null; + sport_type?: string | null; + start_date?: string | null; + start_date_local?: string | null; + average_heartrate?: number | null; + max_heartrate?: number | null; + has_heartrate?: boolean | null; + average_watts?: number | null; + weighted_average_watts?: number | null; + device_watts?: boolean | null; + average_cadence?: number | null; + trainer?: boolean | null; + commute?: boolean | null; + gear_id?: string | null; +} + +/** Strava `DetailedActivity` — the per-activity shape. Superset of the summary + * that additionally carries `calories` (kcal). */ +export interface StravaDetailedActivity extends StravaSummaryActivity { + calories?: number | null; + description?: string | null; +} diff --git a/src/lib/support/report.ts b/src/lib/support/report.ts index d186ee20b..fbbfa8fa3 100644 --- a/src/lib/support/report.ts +++ b/src/lib/support/report.ts @@ -38,12 +38,10 @@ import { PROBLEMS_BUFFER_MAX, } from "@/lib/logging/in-memory-buffer"; import type { WideEvent } from "@/lib/logging/types"; -import { - offhostBackupConfigured, - OFFHOST_BACKUP_QUEUE, -} from "@/lib/jobs/offhost-backup"; +import { offhostBackupConfigured } from "@/lib/jobs/offhost-backup"; +import { OFFHOST_BACKUP_QUEUE } from "@/lib/jobs/offhost-backup-queue"; import { DATA_BACKUP_QUEUE } from "@/lib/jobs/data-backup-policy"; -import { RESTORE_DRILL_QUEUE } from "@/lib/jobs/restore-drill"; +import { RESTORE_DRILL_QUEUE } from "@/lib/jobs/restore-drill-queue"; import { JOB_FAILURE_WINDOW_HOURS, readFailingQueues, diff --git a/src/lib/whoop/__tests__/__fixtures__/body.json b/src/lib/whoop/__tests__/__fixtures__/body.json new file mode 100644 index 000000000..4858df927 --- /dev/null +++ b/src/lib/whoop/__tests__/__fixtures__/body.json @@ -0,0 +1,9 @@ +[ + { "height_meter": 1.8, "weight_kilogram": 78.456, "max_heart_rate": 192 }, + { "height_meter": 1.655, "weight_kilogram": 62.3, "max_heart_rate": 187.6 }, + {}, + { "weight_kilogram": 90 }, + { "height_meter": 0, "weight_kilogram": 0, "max_heart_rate": 0 }, + { "height_meter": -1.7, "weight_kilogram": -70, "max_heart_rate": -180 }, + { "height_meter": 1.72345, "max_heart_rate": 175.4 } +] diff --git a/src/lib/whoop/__tests__/__fixtures__/cycle.json b/src/lib/whoop/__tests__/__fixtures__/cycle.json new file mode 100644 index 000000000..2aef01573 --- /dev/null +++ b/src/lib/whoop/__tests__/__fixtures__/cycle.json @@ -0,0 +1,71 @@ +[ + { + "id": 900001, + "user_id": 10001, + "created_at": "2026-03-01T22:30:00.000Z", + "updated_at": "2026-03-02T22:10:00.000Z", + "start": "2026-03-01T22:30:00.000Z", + "end": "2026-03-02T22:05:00.000Z", + "score_state": "SCORED", + "score": { + "strain": 12.4567, + "kilojoule": 9876.543, + "average_heart_rate": 68.4, + "max_heart_rate": 171.6 + } + }, + { + "id": 900002, + "user_id": 10001, + "created_at": "2026-03-02T22:05:00.000Z", + "updated_at": "2026-03-03T09:00:00.000Z", + "start": "2026-03-02T22:05:00.000Z", + "end": null, + "score_state": "SCORED", + "score": { + "strain": 3.1, + "kilojoule": 2500, + "average_heart_rate": 61.5, + "max_heart_rate": 120.5 + } + }, + { + "id": 900003, + "user_id": 10001, + "created_at": "2026-03-03T22:00:00.000Z", + "updated_at": "2026-03-03T22:00:00.000Z", + "start": "2026-03-03T22:00:00.000Z", + "score_state": "PENDING_SCORE", + "score": null + }, + { + "id": 900004, + "user_id": 10001, + "created_at": "2026-03-04T23:15:00.000+01:00", + "updated_at": "2026-03-05T23:00:00.000+01:00", + "start": "2026-03-04T23:15:00.000+01:00", + "end": "2026-03-05T22:50:00.000+01:00", + "score_state": "SCORED", + "score": { + "strain": 0, + "kilojoule": 0, + "average_heart_rate": 0, + "max_heart_rate": 0 + } + }, + { + "id": 900005, + "user_id": 10001, + "created_at": "2026-03-05T21:00:00.000-05:00", + "updated_at": "2026-03-06T21:00:00.000-05:00", + "start": "2026-03-05T21:00:00.000-05:00", + "end": "2026-03-06T20:45:00.000-05:00", + "score_state": "SCORED", + "score": { + "strain": 21, + "kilojoule": 18000.005, + "average_heart_rate": 95, + "max_heart_rate": 198 + } + } +] diff --git a/src/lib/whoop/__tests__/__fixtures__/recovery.json b/src/lib/whoop/__tests__/__fixtures__/recovery.json new file mode 100644 index 000000000..c2ff504a9 --- /dev/null +++ b/src/lib/whoop/__tests__/__fixtures__/recovery.json @@ -0,0 +1,81 @@ +[ + { + "cycle_id": 900001, + "sleep_id": "sleep-0001", + "user_id": 10001, + "created_at": "2026-03-02T06:40:00.000Z", + "updated_at": "2026-03-02T06:45:00.000Z", + "score_state": "SCORED", + "score": { + "user_calibrating": false, + "recovery_score": 64, + "resting_heart_rate": 54, + "hrv_rmssd_milli": 48.2, + "spo2_percentage": 96.5, + "skin_temp_celsius": 33.4 + } + }, + { + "cycle_id": 900002, + "sleep_id": "sleep-0002", + "user_id": 10001, + "created_at": "2026-03-03T06:40:00.000Z", + "updated_at": "2026-03-03T06:41:00.000Z", + "score_state": "SCORED", + "score": { + "user_calibrating": false, + "recovery_score": 81, + "resting_heart_rate": 50, + "hrv_rmssd_milli": 61.73456 + } + }, + { + "cycle_id": 900003, + "sleep_id": "sleep-0003", + "user_id": 10001, + "created_at": "2026-03-04T06:40:00.000Z", + "updated_at": "2026-03-04T06:40:00.000Z", + "score_state": "PENDING_SCORE", + "score": null + }, + { + "cycle_id": 900004, + "sleep_id": "sleep-0004", + "user_id": 10001, + "created_at": "2026-03-05T06:40:00.000Z", + "updated_at": "2026-03-05T06:40:00.000Z", + "score_state": "UNSCORABLE", + "score": null + }, + { + "cycle_id": 900005, + "sleep_id": "sleep-0005", + "user_id": 10001, + "created_at": "2026-03-06T05:00:00.000Z", + "updated_at": "2026-03-06T07:15:30.000+02:00", + "score_state": "SCORED", + "score": { + "user_calibrating": true, + "recovery_score": 0, + "resting_heart_rate": 0, + "hrv_rmssd_milli": 0, + "spo2_percentage": 0, + "skin_temp_celsius": 0 + } + }, + { + "cycle_id": 900006, + "sleep_id": "sleep-0006", + "user_id": 10001, + "created_at": "2026-03-07T06:40:00.000Z", + "updated_at": "2026-03-07T01:30:00.000-05:00", + "score_state": "SCORED", + "score": { + "user_calibrating": false, + "recovery_score": 100, + "resting_heart_rate": 41.666, + "hrv_rmssd_milli": 132.005, + "skin_temp_celsius": 34.125 + } + } +] diff --git a/src/lib/whoop/__tests__/__fixtures__/sleep.json b/src/lib/whoop/__tests__/__fixtures__/sleep.json new file mode 100644 index 000000000..e58450e61 --- /dev/null +++ b/src/lib/whoop/__tests__/__fixtures__/sleep.json @@ -0,0 +1,139 @@ +[ + { + "id": "sleep-0001", + "cycle_id": 900001, + "user_id": 10001, + "created_at": "2026-03-02T06:40:00.000Z", + "updated_at": "2026-03-02T06:45:00.000Z", + "start": "2026-03-01T22:30:00.000Z", + "end": "2026-03-02T06:30:00.000Z", + "nap": false, + "score_state": "SCORED", + "score": { + "stage_summary": { + "total_in_bed_time_milli": 28800000, + "total_awake_time_milli": 1800000, + "total_light_sleep_time_milli": 14400000, + "total_slow_wave_sleep_time_milli": 5400000, + "total_rem_sleep_time_milli": 7200000, + "disturbance_count": 7 + }, + "sleep_needed": { + "baseline_milli": 27000000, + "need_from_sleep_debt_milli": 1800000, + "need_from_recent_strain_milli": 900000, + "need_from_recent_nap_milli": -600000 + }, + "respiratory_rate": 15.234, + "sleep_performance_percentage": 88, + "sleep_consistency_percentage": 72, + "sleep_efficiency_percentage": 93.75 + } + }, + { + "id": "sleep-0002", + "user_id": 10001, + "created_at": "2026-03-02T14:50:00.000Z", + "updated_at": "2026-03-02T14:52:00.000Z", + "start": "2026-03-02T14:00:00.000Z", + "end": "2026-03-02T14:45:00.000Z", + "nap": true, + "score_state": "SCORED", + "score": { + "stage_summary": { + "total_in_bed_time_milli": 2700000, + "total_awake_time_milli": 0, + "total_light_sleep_time_milli": 2100000, + "total_slow_wave_sleep_time_milli": 600000, + "total_rem_sleep_time_milli": 0 + }, + "sleep_needed": { + "baseline_milli": 0, + "need_from_sleep_debt_milli": 0, + "need_from_recent_strain_milli": 0, + "need_from_recent_nap_milli": 0 + } + } + }, + { + "id": "sleep-0003", + "cycle_id": 900003, + "user_id": 10001, + "created_at": "2026-03-04T06:40:00.000Z", + "updated_at": "2026-03-04T06:40:00.000Z", + "start": "2026-03-03T23:00:00.000Z", + "end": "2026-03-04T06:30:00.000Z", + "nap": false, + "score_state": "PENDING_SCORE", + "score": null + }, + { + "id": "sleep-0004", + "cycle_id": 900004, + "user_id": 10001, + "created_at": "2026-03-05T07:10:00.000+01:00", + "updated_at": "2026-03-05T07:12:00.000+01:00", + "start": "2026-03-04T23:40:00.000+01:00", + "end": "2026-03-05T07:05:00.000+01:00", + "nap": false, + "score_state": "SCORED", + "score": { + "stage_summary": { + "total_in_bed_time_milli": 26700000, + "total_awake_time_milli": 2345678, + "total_light_sleep_time_milli": 12345678, + "total_slow_wave_sleep_time_milli": 4567890, + "total_rem_sleep_time_milli": 6543210, + "disturbance_count": 0 + }, + "sleep_needed": { + "baseline_milli": 28123456, + "need_from_sleep_debt_milli": 0, + "need_from_recent_strain_milli": 456789, + "need_from_recent_nap_milli": 0 + }, + "respiratory_rate": 0, + "sleep_performance_percentage": 0, + "sleep_efficiency_percentage": 100 + } + }, + { + "id": "sleep-0005", + "cycle_id": 900005, + "user_id": 10001, + "created_at": "2026-03-06T06:00:00.000-05:00", + "updated_at": "2026-03-06T06:05:00.000-05:00", + "start": "2026-03-05T22:15:00.000-05:00", + "end": "2026-03-06T05:45:00.000-05:00", + "nap": false, + "score_state": "SCORED", + "score": { + "stage_summary": { + "total_in_bed_time_milli": 0, + "total_awake_time_milli": 0, + "total_light_sleep_time_milli": 0, + "total_slow_wave_sleep_time_milli": 0, + "total_rem_sleep_time_milli": 0 + }, + "sleep_needed": { + "baseline_milli": 25200000, + "need_from_sleep_debt_milli": 3600000, + "need_from_recent_strain_milli": 0, + "need_from_recent_nap_milli": 0 + }, + "sleep_consistency_percentage": 55 + } + }, + { + "id": "sleep-0006", + "cycle_id": 900006, + "user_id": 10001, + "created_at": "2026-03-07T08:00:00.000Z", + "updated_at": "2026-03-07T08:00:00.000Z", + "start": "2026-03-07T00:10:00.000Z", + "end": "2026-03-07T07:50:00.000Z", + "nap": false, + "score_state": "UNSCORABLE", + "score": null + } +] diff --git a/src/lib/whoop/__tests__/__golden__/map-body.json b/src/lib/whoop/__tests__/__golden__/map-body.json new file mode 100644 index 000000000..fb4116598 --- /dev/null +++ b/src/lib/whoop/__tests__/__golden__/map-body.json @@ -0,0 +1,42 @@ +[ + { + "input": { + "height_meter": 1.8, + "weight_kilogram": 78.456, + "max_heart_rate": 192 + }, + "mapped": { "weightKg": 78.46, "maxHeartRate": 192, "heightCm": 180 } + }, + { + "input": { + "height_meter": 1.655, + "weight_kilogram": 62.3, + "max_heart_rate": 187.6 + }, + "mapped": { "weightKg": 62.3, "maxHeartRate": 188, "heightCm": 165.5 } + }, + { + "input": {}, + "mapped": { "weightKg": null, "maxHeartRate": null, "heightCm": null } + }, + { + "input": { "weight_kilogram": 90 }, + "mapped": { "weightKg": 90, "maxHeartRate": null, "heightCm": null } + }, + { + "input": { "height_meter": 0, "weight_kilogram": 0, "max_heart_rate": 0 }, + "mapped": { "weightKg": null, "maxHeartRate": null, "heightCm": null } + }, + { + "input": { + "height_meter": -1.7, + "weight_kilogram": -70, + "max_heart_rate": -180 + }, + "mapped": { "weightKg": null, "maxHeartRate": null, "heightCm": null } + }, + { + "input": { "height_meter": 1.72345, "max_heart_rate": 175.4 }, + "mapped": { "weightKg": null, "maxHeartRate": 175, "heightCm": 172.35 } + } +] diff --git a/src/lib/whoop/__tests__/__golden__/map-cycle.json b/src/lib/whoop/__tests__/__golden__/map-cycle.json new file mode 100644 index 000000000..5ac64675a --- /dev/null +++ b/src/lib/whoop/__tests__/__golden__/map-cycle.json @@ -0,0 +1,135 @@ +[ + { + "id": 900001, + "rows": [ + { + "type": "DAY_STRAIN", + "value": 12.46, + "unit": "score", + "measuredAt": "2026-03-01T22:30:00.000Z", + "fieldTag": "day_strain" + }, + { + "type": "ENERGY_EXPENDITURE_KJ", + "value": 9876.54, + "unit": "kJ", + "measuredAt": "2026-03-01T22:30:00.000Z", + "fieldTag": "energy_kj" + }, + { + "type": "AVERAGE_HEART_RATE", + "value": 68, + "unit": "bpm", + "measuredAt": "2026-03-01T22:30:00.000Z", + "fieldTag": "avg_hr" + }, + { + "type": "MAX_HEART_RATE", + "value": 172, + "unit": "bpm", + "measuredAt": "2026-03-01T22:30:00.000Z", + "fieldTag": "max_hr" + } + ] + }, + { + "id": 900002, + "rows": [ + { + "type": "DAY_STRAIN", + "value": 3.1, + "unit": "score", + "measuredAt": "2026-03-02T22:05:00.000Z", + "fieldTag": "day_strain" + }, + { + "type": "ENERGY_EXPENDITURE_KJ", + "value": 2500, + "unit": "kJ", + "measuredAt": "2026-03-02T22:05:00.000Z", + "fieldTag": "energy_kj" + }, + { + "type": "AVERAGE_HEART_RATE", + "value": 62, + "unit": "bpm", + "measuredAt": "2026-03-02T22:05:00.000Z", + "fieldTag": "avg_hr" + }, + { + "type": "MAX_HEART_RATE", + "value": 121, + "unit": "bpm", + "measuredAt": "2026-03-02T22:05:00.000Z", + "fieldTag": "max_hr" + } + ] + }, + { "id": 900003, "rows": [] }, + { + "id": 900004, + "rows": [ + { + "type": "DAY_STRAIN", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-04T22:15:00.000Z", + "fieldTag": "day_strain" + }, + { + "type": "ENERGY_EXPENDITURE_KJ", + "value": 0, + "unit": "kJ", + "measuredAt": "2026-03-04T22:15:00.000Z", + "fieldTag": "energy_kj" + }, + { + "type": "AVERAGE_HEART_RATE", + "value": 0, + "unit": "bpm", + "measuredAt": "2026-03-04T22:15:00.000Z", + "fieldTag": "avg_hr" + }, + { + "type": "MAX_HEART_RATE", + "value": 0, + "unit": "bpm", + "measuredAt": "2026-03-04T22:15:00.000Z", + "fieldTag": "max_hr" + } + ] + }, + { + "id": 900005, + "rows": [ + { + "type": "DAY_STRAIN", + "value": 21, + "unit": "score", + "measuredAt": "2026-03-06T02:00:00.000Z", + "fieldTag": "day_strain" + }, + { + "type": "ENERGY_EXPENDITURE_KJ", + "value": 18000.01, + "unit": "kJ", + "measuredAt": "2026-03-06T02:00:00.000Z", + "fieldTag": "energy_kj" + }, + { + "type": "AVERAGE_HEART_RATE", + "value": 95, + "unit": "bpm", + "measuredAt": "2026-03-06T02:00:00.000Z", + "fieldTag": "avg_hr" + }, + { + "type": "MAX_HEART_RATE", + "value": 198, + "unit": "bpm", + "measuredAt": "2026-03-06T02:00:00.000Z", + "fieldTag": "max_hr" + } + ] + } +] diff --git a/src/lib/whoop/__tests__/__golden__/map-recovery.json b/src/lib/whoop/__tests__/__golden__/map-recovery.json new file mode 100644 index 000000000..79fdcc46c --- /dev/null +++ b/src/lib/whoop/__tests__/__golden__/map-recovery.json @@ -0,0 +1,143 @@ +[ + { + "sleep_id": "sleep-0001", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 64, + "unit": "score", + "measuredAt": "2026-03-02T06:45:00.000Z", + "fieldTag": "recovery" + }, + { + "type": "HRV_RMSSD", + "value": 48.2, + "unit": "ms", + "measuredAt": "2026-03-02T06:45:00.000Z", + "fieldTag": "hrv_rmssd" + }, + { + "type": "RESTING_HEART_RATE", + "value": 54, + "unit": "bpm", + "measuredAt": "2026-03-02T06:45:00.000Z", + "fieldTag": "rhr" + }, + { + "type": "OXYGEN_SATURATION", + "value": 96.5, + "unit": "%", + "measuredAt": "2026-03-02T06:45:00.000Z", + "fieldTag": "spo2" + }, + { + "type": "SKIN_TEMPERATURE", + "value": 33.4, + "unit": "celsius", + "measuredAt": "2026-03-02T06:45:00.000Z", + "fieldTag": "skin_temp" + } + ] + }, + { + "sleep_id": "sleep-0002", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 81, + "unit": "score", + "measuredAt": "2026-03-03T06:41:00.000Z", + "fieldTag": "recovery" + }, + { + "type": "HRV_RMSSD", + "value": 61.73, + "unit": "ms", + "measuredAt": "2026-03-03T06:41:00.000Z", + "fieldTag": "hrv_rmssd" + }, + { + "type": "RESTING_HEART_RATE", + "value": 50, + "unit": "bpm", + "measuredAt": "2026-03-03T06:41:00.000Z", + "fieldTag": "rhr" + } + ] + }, + { "sleep_id": "sleep-0003", "rows": [] }, + { "sleep_id": "sleep-0004", "rows": [] }, + { + "sleep_id": "sleep-0005", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-06T05:15:30.000Z", + "fieldTag": "recovery" + }, + { + "type": "HRV_RMSSD", + "value": 0, + "unit": "ms", + "measuredAt": "2026-03-06T05:15:30.000Z", + "fieldTag": "hrv_rmssd" + }, + { + "type": "RESTING_HEART_RATE", + "value": 0, + "unit": "bpm", + "measuredAt": "2026-03-06T05:15:30.000Z", + "fieldTag": "rhr" + }, + { + "type": "OXYGEN_SATURATION", + "value": 0, + "unit": "%", + "measuredAt": "2026-03-06T05:15:30.000Z", + "fieldTag": "spo2" + }, + { + "type": "SKIN_TEMPERATURE", + "value": 0, + "unit": "celsius", + "measuredAt": "2026-03-06T05:15:30.000Z", + "fieldTag": "skin_temp" + } + ] + }, + { + "sleep_id": "sleep-0006", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 100, + "unit": "score", + "measuredAt": "2026-03-07T06:30:00.000Z", + "fieldTag": "recovery" + }, + { + "type": "HRV_RMSSD", + "value": 132.01, + "unit": "ms", + "measuredAt": "2026-03-07T06:30:00.000Z", + "fieldTag": "hrv_rmssd" + }, + { + "type": "RESTING_HEART_RATE", + "value": 41.67, + "unit": "bpm", + "measuredAt": "2026-03-07T06:30:00.000Z", + "fieldTag": "rhr" + }, + { + "type": "SKIN_TEMPERATURE", + "value": 34.13, + "unit": "celsius", + "measuredAt": "2026-03-07T06:30:00.000Z", + "fieldTag": "skin_temp" + } + ] + } +] diff --git a/src/lib/whoop/__tests__/__golden__/map-sleep.json b/src/lib/whoop/__tests__/__golden__/map-sleep.json new file mode 100644 index 000000000..d5082d292 --- /dev/null +++ b/src/lib/whoop/__tests__/__golden__/map-sleep.json @@ -0,0 +1,246 @@ +[ + { + "id": "sleep-0001", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 30, + "unit": "minutes", + "measuredAt": "2026-03-01T23:00:00.000Z", + "fieldTag": "sleep_awake", + "externalId": "sleep-0001:seg:sleep_awake", + "sleepStage": "AWAKE", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 240, + "unit": "minutes", + "measuredAt": "2026-03-02T03:00:00.000Z", + "fieldTag": "sleep_core", + "externalId": "sleep-0001:seg:sleep_core", + "sleepStage": "CORE", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 90, + "unit": "minutes", + "measuredAt": "2026-03-02T04:30:00.000Z", + "fieldTag": "sleep_deep", + "externalId": "sleep-0001:seg:sleep_deep", + "sleepStage": "DEEP", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 120, + "unit": "minutes", + "measuredAt": "2026-03-02T06:30:00.000Z", + "fieldTag": "sleep_rem", + "externalId": "sleep-0001:seg:sleep_rem", + "sleepStage": "REM", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 480, + "unit": "minutes", + "measuredAt": "2026-03-02T06:30:00.000Z", + "fieldTag": "sleep_in_bed", + "sleepStage": "IN_BED" + }, + { + "type": "SLEEP_NEED", + "value": 485, + "unit": "minutes", + "measuredAt": "2026-03-02T06:30:00.000Z", + "fieldTag": "sleep_need" + }, + { + "type": "SLEEP_PERFORMANCE", + "value": 88, + "unit": "%", + "measuredAt": "2026-03-02T06:30:00.000Z", + "fieldTag": "sleep_perf" + }, + { + "type": "SLEEP_EFFICIENCY", + "value": 93.75, + "unit": "%", + "measuredAt": "2026-03-02T06:30:00.000Z", + "fieldTag": "sleep_eff" + }, + { + "type": "SLEEP_CONSISTENCY", + "value": 72, + "unit": "%", + "measuredAt": "2026-03-02T06:30:00.000Z", + "fieldTag": "sleep_consistency" + }, + { + "type": "RESPIRATORY_RATE", + "value": 15.23, + "unit": "breaths/min", + "measuredAt": "2026-03-02T06:30:00.000Z", + "fieldTag": "resp_rate" + }, + { + "type": "SLEEP_DISTURBANCE_COUNT", + "value": 7, + "unit": "count", + "measuredAt": "2026-03-02T06:30:00.000Z", + "fieldTag": "disturbances" + } + ] + }, + { + "id": "sleep-0002", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 35, + "unit": "minutes", + "measuredAt": "2026-03-02T14:35:00.000Z", + "fieldTag": "sleep_core", + "externalId": "sleep-0002:seg:sleep_core", + "sleepStage": "CORE", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 10, + "unit": "minutes", + "measuredAt": "2026-03-02T14:45:00.000Z", + "fieldTag": "sleep_deep", + "externalId": "sleep-0002:seg:sleep_deep", + "sleepStage": "DEEP", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 45, + "unit": "minutes", + "measuredAt": "2026-03-02T14:45:00.000Z", + "fieldTag": "sleep_in_bed", + "sleepStage": "IN_BED" + }, + { + "type": "SLEEP_NEED", + "value": 0, + "unit": "minutes", + "measuredAt": "2026-03-02T14:45:00.000Z", + "fieldTag": "sleep_need" + } + ] + }, + { "id": "sleep-0003", "rows": [] }, + { + "id": "sleep-0004", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 39.09, + "unit": "minutes", + "measuredAt": "2026-03-04T23:19:05.678Z", + "fieldTag": "sleep_awake", + "externalId": "sleep-0004:seg:sleep_awake", + "sleepStage": "AWAKE", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 205.76, + "unit": "minutes", + "measuredAt": "2026-03-05T02:44:51.356Z", + "fieldTag": "sleep_core", + "externalId": "sleep-0004:seg:sleep_core", + "sleepStage": "CORE", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 76.13, + "unit": "minutes", + "measuredAt": "2026-03-05T04:00:59.246Z", + "fieldTag": "sleep_deep", + "externalId": "sleep-0004:seg:sleep_deep", + "sleepStage": "DEEP", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 109.05, + "unit": "minutes", + "measuredAt": "2026-03-05T05:50:02.456Z", + "fieldTag": "sleep_rem", + "externalId": "sleep-0004:seg:sleep_rem", + "sleepStage": "REM", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 445, + "unit": "minutes", + "measuredAt": "2026-03-05T06:05:00.000Z", + "fieldTag": "sleep_in_bed", + "sleepStage": "IN_BED" + }, + { + "type": "SLEEP_NEED", + "value": 476.34, + "unit": "minutes", + "measuredAt": "2026-03-05T06:05:00.000Z", + "fieldTag": "sleep_need" + }, + { + "type": "SLEEP_PERFORMANCE", + "value": 0, + "unit": "%", + "measuredAt": "2026-03-05T06:05:00.000Z", + "fieldTag": "sleep_perf" + }, + { + "type": "SLEEP_EFFICIENCY", + "value": 100, + "unit": "%", + "measuredAt": "2026-03-05T06:05:00.000Z", + "fieldTag": "sleep_eff" + }, + { + "type": "RESPIRATORY_RATE", + "value": 0, + "unit": "breaths/min", + "measuredAt": "2026-03-05T06:05:00.000Z", + "fieldTag": "resp_rate" + }, + { + "type": "SLEEP_DISTURBANCE_COUNT", + "value": 0, + "unit": "count", + "measuredAt": "2026-03-05T06:05:00.000Z", + "fieldTag": "disturbances" + } + ] + }, + { + "id": "sleep-0005", + "rows": [ + { + "type": "SLEEP_NEED", + "value": 480, + "unit": "minutes", + "measuredAt": "2026-03-06T10:45:00.000Z", + "fieldTag": "sleep_need" + }, + { + "type": "SLEEP_CONSISTENCY", + "value": 55, + "unit": "%", + "measuredAt": "2026-03-06T10:45:00.000Z", + "fieldTag": "sleep_consistency" + } + ] + }, + { "id": "sleep-0006", "rows": [] } +] diff --git a/src/lib/whoop/__tests__/__golden__/map-sport.json b/src/lib/whoop/__tests__/__golden__/map-sport.json new file mode 100644 index 000000000..2a0d6fe44 --- /dev/null +++ b/src/lib/whoop/__tests__/__golden__/map-sport.json @@ -0,0 +1,27 @@ +[ + { "sportId": 0, "sportName": "Running", "sportType": "running" }, + { "sportId": 1, "sportName": null, "sportType": "cycling" }, + { + "sportId": 52, + "sportName": "ignored when the id resolves", + "sportType": "hiking" + }, + { "sportId": 9999, "sportName": "Hiking/Rucking", "sportType": "hiking" }, + { "sportId": null, "sportName": "hiking_rucking", "sportType": "hiking" }, + { "sportId": null, "sportName": " Track & Field ", "sportType": "running" }, + { + "sportId": null, + "sportName": "Functional Fitness", + "sportType": "crossTraining" + }, + { "sportId": null, "sportName": "whoop_sport_63", "sportType": "other" }, + { + "sportId": null, + "sportName": "Underwater Basket Weaving", + "sportType": "other" + }, + { "sportId": null, "sportName": " ", "sportType": "other" }, + { "sportId": 9999, "sportName": null, "sportType": "other" }, + { "sportId": -1, "sportName": null, "sportType": "other" }, + { "sportId": null, "sportName": null, "sportType": "other" } +] diff --git a/src/lib/whoop/__tests__/client.test.ts b/src/lib/whoop/__tests__/client.test.ts index fa0db0f1b..eb83dfb5f 100644 --- a/src/lib/whoop/__tests__/client.test.ts +++ b/src/lib/whoop/__tests__/client.test.ts @@ -1,10 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { - KJ_TO_KCAL, - WHOOP_FIELD_MAP, - WHOOP_OAUTH_SCOPE, WHOOP_PAGE_LIMIT, - exchangeCode, fetchBodyMeasurement, fetchRecoveryByCycleId, fetchSleepById, @@ -15,16 +11,22 @@ import { fetchRecoveries, fetchSleeps, fetchWorkouts, - getAuthorizationUrl, +} from "../client"; +import { + KJ_TO_KCAL, + WHOOP_FIELD_MAP, mapCycle, mapRecovery, mapSleep, - refreshAccessToken, +} from "@/lib/whoop/map"; +import { type WhoopCycle, type WhoopRecovery, type WhoopSleep, -} from "../client"; +} from "@/lib/whoop/wire-types"; +import { WHOOP_OAUTH_SCOPE } from "@/lib/whoop/oauth"; import { WhoopApiError } from "../response-classifier"; +import { whoopOAuth } from "../oauth"; const CREDS = { clientId: "cid", clientSecret: "csecret" }; @@ -50,7 +52,7 @@ afterEach(() => { describe("getAuthorizationUrl", () => { it("builds the v2 authorize URL with the offline scope and state", () => { - const url = getAuthorizationUrl("nonce123", CREDS); + const url = whoopOAuth.authorizationUrl("nonce123", CREDS); expect(url).toContain("api.prod.whoop.com/oauth/oauth2/auth"); expect(url).toContain("response_type=code"); expect(url).toContain("client_id=cid"); @@ -76,7 +78,7 @@ describe("token exchange + refresh", () => { }, }, ]); - const tok = await exchangeCode("code", CREDS); + const tok = await whoopOAuth.exchange("code", CREDS); expect(tok.access_token).toBe("at"); expect(tok.refresh_token).toBe("rt"); expect(tok.expires_in).toBe(3600); @@ -89,7 +91,7 @@ describe("token exchange + refresh", () => { body: { access_token: "at2", refresh_token: "rt2", expires_in: 3600 }, }, ]); - const tok = await refreshAccessToken("rt1", CREDS); + const tok = await whoopOAuth.refresh("rt1", CREDS); expect(tok.refresh_token).toBe("rt2"); const [, init] = fetchMock.mock.calls[0] as unknown as [ string, @@ -101,7 +103,7 @@ describe("token exchange + refresh", () => { it("throws a classified WhoopApiError on a 401 token response", async () => { installFetchMock([{ status: 401, body: { error: "invalid_grant" } }]); - await expect(exchangeCode("bad", CREDS)).rejects.toMatchObject({ + await expect(whoopOAuth.exchange("bad", CREDS)).rejects.toMatchObject({ name: "WhoopApiError", classification: "reauth_required", }); diff --git a/src/lib/whoop/__tests__/map-fixtures.test.ts b/src/lib/whoop/__tests__/map-fixtures.test.ts new file mode 100644 index 000000000..fe2506787 --- /dev/null +++ b/src/lib/whoop/__tests__/map-fixtures.test.ts @@ -0,0 +1,106 @@ +/** + * What each WHOOP mapper makes of a fixed set of payloads, pinned in + * `__golden__/`. + * + * The fixtures under `__fixtures__/` are synthetic payloads in the documented + * v2 wire shape: scored and unscored records, naps, absent optional fields, + * zero values, offsets other than UTC, and body values that must be refused. + * The goldens were written while the mappers still lived in `client.ts`, so a + * green run after they move proves the move changed nothing. + * + * The workout row is built inside `upsertWhoopWorkout`, next to the database + * write, so only its pure sport lookup (`mapWhoopSportType`) is pinned here. + */ +import { format } from "prettier"; +import { describe, expect, it } from "vitest"; + +import { mapBody, mapCycle, mapRecovery, mapSleep } from "@/lib/whoop/map"; +import { + type WhoopBodyMeasurement, + type WhoopCycle, + type WhoopRecovery, + type WhoopSleep, +} from "@/lib/whoop/wire-types"; +import { mapWhoopSportType } from "../sport-map"; +import bodyFixture from "./__fixtures__/body.json"; +import cycleFixture from "./__fixtures__/cycle.json"; +import recoveryFixture from "./__fixtures__/recovery.json"; +import sleepFixture from "./__fixtures__/sleep.json"; + +const recoveries: readonly WhoopRecovery[] = recoveryFixture; +const sleeps: readonly WhoopSleep[] = sleepFixture; +const cycles: readonly WhoopCycle[] = cycleFixture; +const bodies: readonly WhoopBodyMeasurement[] = bodyFixture; + +/** The golden is written in the repository's JSON format, so the format check passes on it unchanged. */ +async function golden(value: unknown): Promise { + return format(JSON.stringify(value), { parser: "json" }); +} + +describe("WHOOP mappers against the fixture payloads", () => { + it("maps recoveries", async () => { + const output = recoveries.map((record) => ({ + sleep_id: record.sleep_id, + rows: mapRecovery(record), + })); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-recovery.json", + ); + }); + + it("maps sleeps", async () => { + const output = sleeps.map((record) => ({ + id: record.id, + rows: mapSleep(record), + })); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-sleep.json", + ); + }); + + it("maps cycles", async () => { + const output = cycles.map((record) => ({ + id: record.id, + rows: mapCycle(record), + })); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-cycle.json", + ); + }); + + it("maps body measurements", async () => { + const output = bodies.map((record) => ({ + input: record, + mapped: mapBody(record), + })); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-body.json", + ); + }); + + it("resolves workout sports", async () => { + const inputs: Array<[number | undefined, string | undefined]> = [ + [0, "Running"], + [1, undefined], + [52, "ignored when the id resolves"], + [9999, "Hiking/Rucking"], + [undefined, "hiking_rucking"], + [undefined, " Track & Field "], + [undefined, "Functional Fitness"], + [undefined, "whoop_sport_63"], + [undefined, "Underwater Basket Weaving"], + [undefined, " "], + [9999, undefined], + [-1, undefined], + [undefined, undefined], + ]; + const output = inputs.map(([sportId, sportName]) => ({ + sportId: sportId ?? null, + sportName: sportName ?? null, + sportType: mapWhoopSportType(sportId, sportName), + })); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-sport.json", + ); + }); +}); diff --git a/src/lib/whoop/__tests__/sync-body.test.ts b/src/lib/whoop/__tests__/sync-body.test.ts index 4237ff0fc..c87dfa244 100644 --- a/src/lib/whoop/__tests__/sync-body.test.ts +++ b/src/lib/whoop/__tests__/sync-body.test.ts @@ -76,7 +76,7 @@ vi.mock("../sync-core", async (orig) => { }; }); -import { mapBody } from "../client"; +import { mapBody } from "@/lib/whoop/map"; import { syncUserBody, WHOOP_BODY_WEIGHT_EXTERNAL_ID } from "../sync-body"; import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; diff --git a/src/lib/whoop/__tests__/sync-dead-token.test.ts b/src/lib/whoop/__tests__/sync-dead-token.test.ts index a5d51027e..46f676086 100644 --- a/src/lib/whoop/__tests__/sync-dead-token.test.ts +++ b/src/lib/whoop/__tests__/sync-dead-token.test.ts @@ -71,7 +71,7 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({ collapseToTypeDayKeys: vi.fn(() => []), recomputeBucketsForMeasurement: vi.fn(async () => {}), })); -vi.mock("@/lib/insights/comprehensive-generate", () => ({ +vi.mock("@/lib/insights/status-invalidation", () => ({ invalidateStatusInsightsForTypes: vi.fn(async () => {}), })); vi.mock("@/lib/arrivals/measurement-emit", () => ({ @@ -84,9 +84,12 @@ vi.mock("@/lib/logging/context", () => ({ vi.mock("../credentials", () => ({ getUserWhoopCredentials: getUserWhoopCredentialsMock, })); -vi.mock("../client", async (orig) => { - const actual = await orig(); - return { ...actual, refreshAccessToken: refreshAccessTokenMock }; +vi.mock("../oauth", async (orig) => { + const actual = await orig(); + return { + ...actual, + whoopOAuth: { ...actual.whoopOAuth, refresh: refreshAccessTokenMock }, + }; }); // Each resource resolves the token first (the ledger-registration seam). diff --git a/src/lib/whoop/__tests__/sync-sleep-sweep.test.ts b/src/lib/whoop/__tests__/sync-sleep-sweep.test.ts index aadc10620..b20010152 100644 --- a/src/lib/whoop/__tests__/sync-sleep-sweep.test.ts +++ b/src/lib/whoop/__tests__/sync-sleep-sweep.test.ts @@ -64,7 +64,7 @@ vi.mock("../client", async (importOriginal) => ({ fetchSleepById: fetchSleepsMock, })); -import type { WhoopSleep } from "../client"; +import type { WhoopSleep } from "@/lib/whoop/wire-types"; import { syncUserSleep } from "../sync-sleep"; const NIGHT: WhoopSleep = { diff --git a/src/lib/whoop/__tests__/sync-user-whoop.test.ts b/src/lib/whoop/__tests__/sync-user-whoop.test.ts index 2e4c30e30..c74c91b04 100644 --- a/src/lib/whoop/__tests__/sync-user-whoop.test.ts +++ b/src/lib/whoop/__tests__/sync-user-whoop.test.ts @@ -55,7 +55,7 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({ collapseToTypeDayKeys: () => [], recomputeBucketsForMeasurement: vi.fn(async () => {}), })); -vi.mock("@/lib/insights/comprehensive-generate", () => ({ +vi.mock("@/lib/insights/status-invalidation", () => ({ invalidateStatusInsightsForTypes: vi.fn(async () => {}), })); diff --git a/src/lib/whoop/__tests__/sync-workout.test.ts b/src/lib/whoop/__tests__/sync-workout.test.ts index ac995ac3c..c853b108f 100644 --- a/src/lib/whoop/__tests__/sync-workout.test.ts +++ b/src/lib/whoop/__tests__/sync-workout.test.ts @@ -47,7 +47,7 @@ vi.mock("@/lib/logging/context", () => ({ })); import { upsertWhoopWorkout } from "../sync-workout"; -import type { WhoopWorkout } from "../client"; +import type { WhoopWorkout } from "@/lib/whoop/wire-types"; function scoredWorkout(overrides: Partial): WhoopWorkout { return { diff --git a/src/lib/whoop/__tests__/sync.test.ts b/src/lib/whoop/__tests__/sync.test.ts index 85462399e..e05221176 100644 --- a/src/lib/whoop/__tests__/sync.test.ts +++ b/src/lib/whoop/__tests__/sync.test.ts @@ -60,11 +60,20 @@ vi.mock("@/lib/crypto", () => ({ decrypt: (s: string) => s.replace(/^enc\(|\)$/g, ""), })); +vi.mock("../oauth", async (orig) => { + const actual = await orig(); + return { + ...actual, + whoopOAuth: { + ...actual.whoopOAuth, + refresh: (...a: unknown[]) => refreshAccessTokenMock(...a), + }, + }; +}); vi.mock("../client", async (orig) => { const actual = await orig(); return { ...actual, - refreshAccessToken: (...a: unknown[]) => refreshAccessTokenMock(...a), fetchRecoveries: (...a: unknown[]) => fetchRecoveriesMock(...a), }; }); @@ -99,7 +108,7 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({ }, })); -vi.mock("@/lib/insights/comprehensive-generate", () => ({ +vi.mock("@/lib/insights/status-invalidation", () => ({ invalidateStatusInsightsForTypes: (...a: unknown[]) => invalidateStatusMock(...a), })); diff --git a/src/lib/whoop/__tests__/webhook-notification.test.ts b/src/lib/whoop/__tests__/webhook-notification.test.ts index ba08d8caa..385ca48cd 100644 --- a/src/lib/whoop/__tests__/webhook-notification.test.ts +++ b/src/lib/whoop/__tests__/webhook-notification.test.ts @@ -62,9 +62,8 @@ import { mapRecovery, mapSleep, WHOOP_RECOVERY_FIELD_TAGS, - type WhoopRecovery, - type WhoopSleep, -} from "../client"; +} from "@/lib/whoop/map"; +import { type WhoopRecovery, type WhoopSleep } from "@/lib/whoop/wire-types"; import type { Prisma } from "@/generated/prisma/client"; beforeEach(() => { diff --git a/src/lib/whoop/client.ts b/src/lib/whoop/client.ts index 17d2aa529..0117dbf80 100644 --- a/src/lib/whoop/client.ts +++ b/src/lib/whoop/client.ts @@ -1,278 +1,35 @@ /** - * WHOOP API v2 client for OAuth and data fetching. - * Docs: https://developer.whoop.com (re-verify at build — the space moves). + * WHOOP API v2 transport and endpoints: hand-rolled fetch over `safeFetch` + * (no SDK), collection reads with `next_token` pagination and single-record + * reads. Docs: https://developer.whoop.com (re-verify at build; the space + * moves). * - * Mirrors the Withings client structure (`src/lib/withings/client.ts`): - * hand-rolled fetch over `safeFetch` (no SDK), an OAuth handshake - * (`getAuthorizationUrl` / `exchangeCode` / `refreshAccessToken`), typed - * collection fetchers with `next_token` pagination, and a single - * source-of-truth field→Measurement mapping (`WHOOP_FIELD_MAP`, kept in sync - * with `src/lib/whoop/mapping.md`). - * - * v2 only — v1 was removed 2025-10-01. Sleep / workout / recovery ids are - * UUID strings; cycle id is int64. Token TTL 3600 s; refresh tokens ROTATE - * (each refresh invalidates the prior access AND refresh token — the sync - * layer persists BOTH rotated tokens). The `offline` scope is required to - * receive a refresh token at all. + * v2 only (v1 was removed 2025-10-01). Sleep, workout and recovery ids are + * UUID strings; cycle ids are int64. The wire shapes are `./wire-types.ts`, + * the record → measurement mapping is `./map.ts`, the OAuth handshake is + * `./oauth.ts`. */ import { getEvent } from "@/lib/logging/context"; import { safeFetch } from "@/lib/safe-fetch"; -import { reconstructContiguousSleepTimeline } from "@/lib/sleep/reconstruct-timeline"; + import { WhoopApiError, classifyWhoopResponse } from "./response-classifier"; -import { envOr, envValue } from "@/lib/env"; -import { importValue } from "@/lib/integrations/import-value"; + import { walkPages } from "@/lib/integrations/page-walk"; +import type { + WhoopCollection, + WhoopRecovery, + WhoopSleep, + WhoopCycle, + WhoopWorkout, + WhoopBodyMeasurement, + WhoopProfile, +} from "./wire-types"; const WHOOP_API_BASE = "https://api.prod.whoop.com/developer"; -const WHOOP_OAUTH_AUTH_URL = "https://api.prod.whoop.com/oauth/oauth2/auth"; -const WHOOP_OAUTH_TOKEN_URL = "https://api.prod.whoop.com/oauth/oauth2/token"; /** WHOOP collection reads cap `limit` at 25. */ export const WHOOP_PAGE_LIMIT = 25; -export interface WhoopCredentials { - clientId: string; - clientSecret: string; -} - -export function getWhoopRedirectUri(): string { - // An empty or blank value counts as unset: the compose whitelist - // materialises the var as an empty string when unset, which must still fall - // through to the derived URI. - return envOr( - "WHOOP_REDIRECT_URI", - `${envValue("NEXT_PUBLIC_APP_URL")}/api/whoop/callback`, - ); -} - -/** - * OAuth scopes HealthLog requests (space-separated on the wire). `offline` is - * mandatory to receive a refresh token; the rest are read-only collection - * scopes. Request all so the user grants once and every sync resource is - * covered. - */ -export const WHOOP_OAUTH_SCOPE = - "offline read:recovery read:sleep read:workout read:cycles read:profile read:body_measurement" as const; - -/** - * Generate the WHOOP OAuth authorization URL (a browser redirect, not a - * fetch). `state` is the opaque CSRF nonce minted by `oauth-state.ts`. - */ -export function getAuthorizationUrl( - state: string, - creds: WhoopCredentials, -): string { - const params = new URLSearchParams({ - response_type: "code", - client_id: creds.clientId, - redirect_uri: getWhoopRedirectUri(), - scope: WHOOP_OAUTH_SCOPE, - state, - }); - return `${WHOOP_OAUTH_AUTH_URL}?${params}`; -} - -export interface WhoopTokenResponse { - access_token: string; - refresh_token: string; - expires_in: number; - scope?: string; - token_type?: string; -} - -async function postToken( - params: URLSearchParams, - verb: string, -): Promise { - const start = performance.now(); - const res = await safeFetch(WHOOP_OAUTH_TOKEN_URL, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: params.toString(), - }); - - const json = await res.json().catch(() => null); - const verdict = classifyWhoopResponse(res.status); - getEvent()?.addExternalCall({ - service: "whoop", - method: verb, - duration_ms: Math.round(performance.now() - start), - status: res.status, - error: verdict.classification === "success" ? undefined : verdict.reason, - }); - if (verdict.classification !== "success") { - throw new WhoopApiError({ - verb, - classification: verdict.classification, - httpStatus: verdict.httpStatus, - reason: verdict.reason, - upstreamError: typeof json?.error === "string" ? json.error : undefined, - }); - } - return json as WhoopTokenResponse; -} - -/** Exchange an authorization code for the initial token pair. */ -export async function exchangeCode( - code: string, - creds: WhoopCredentials, -): Promise { - return postToken( - new URLSearchParams({ - grant_type: "authorization_code", - code, - client_id: creds.clientId, - client_secret: creds.clientSecret, - redirect_uri: getWhoopRedirectUri(), - }), - "exchangeCode", - ); -} - -/** - * Refresh an expired access token. WHOOP rotates the refresh token on every - * use — the caller MUST persist the returned `refresh_token`, not just the - * access token. `scope` must include `offline` (re-sent here per the WHOOP - * spec) for the rotation to return a fresh refresh token. - */ -export async function refreshAccessToken( - refreshToken: string, - creds: WhoopCredentials, -): Promise { - return postToken( - new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: creds.clientId, - client_secret: creds.clientSecret, - scope: "offline", - }), - "refreshAccessToken", - ); -} - -// ─── Collection envelopes (v2) ───────────────────────────────── - -/** WHOOP paginated collection envelope: `{ records, next_token }`. */ -export interface WhoopCollection { - records: T[]; - next_token?: string | null; -} - -/** Recovery record. `score` is null until WHOOP finishes scoring. */ -export interface WhoopRecovery { - cycle_id: number; - sleep_id: string; - user_id: number; - created_at: string; - updated_at: string; - score_state: string; - score: { - user_calibrating: boolean; - recovery_score: number; - resting_heart_rate: number; - hrv_rmssd_milli: number; - spo2_percentage?: number; - skin_temp_celsius?: number; - } | null; -} - -/** Sleep activity record. */ -export interface WhoopSleep { - id: string; - /** - * The cycle this sleep belongs to. A v2 recovery is read through its cycle, - * and the recovery webhook carries the SLEEP id, so the targeted recovery - * refresh resolves sleep -> cycle -> recovery through this field. - */ - cycle_id?: number; - user_id: number; - created_at: string; - updated_at: string; - start: string; - end: string; - nap: boolean; - score_state: string; - score: { - stage_summary: { - total_in_bed_time_milli: number; - total_awake_time_milli: number; - total_light_sleep_time_milli: number; - total_slow_wave_sleep_time_milli: number; - total_rem_sleep_time_milli: number; - // v1.12.8 — per-night sleep disturbance tally. Optional: older / still- - // scoring records may omit it. - disturbance_count?: number; - }; - sleep_needed: { - baseline_milli: number; - need_from_sleep_debt_milli: number; - need_from_recent_strain_milli: number; - need_from_recent_nap_milli: number; - }; - respiratory_rate?: number; - sleep_performance_percentage?: number; - sleep_consistency_percentage?: number; - sleep_efficiency_percentage?: number; - } | null; -} - -/** Physiological cycle (day) record. `id` is an int64. */ -export interface WhoopCycle { - id: number; - user_id: number; - created_at: string; - updated_at: string; - start: string; - end?: string | null; - score_state: string; - score: { - strain: number; - kilojoule: number; - average_heart_rate: number; - max_heart_rate: number; - } | null; -} - -/** Workout activity record. */ -export interface WhoopWorkout { - id: string; - user_id: number; - created_at: string; - updated_at: string; - start: string; - end: string; - sport_id?: number; - sport_name?: string; - score_state: string; - score: { - strain: number; - average_heart_rate: number; - max_heart_rate: number; - kilojoule: number; - percent_recorded: number; - distance_meter?: number; - altitude_gain_meter?: number; - altitude_change_meter?: number; - zone_durations?: Record; - } | null; -} - -/** Body-measurement object (single, not paginated). */ -export interface WhoopBodyMeasurement { - height_meter?: number; - weight_kilogram?: number; - max_heart_rate?: number; -} - -/** Basic profile object (single, not paginated). */ -export interface WhoopProfile { - user_id: number; - email?: string; - first_name?: string; - last_name?: string; -} - interface CollectionQuery { start?: Date; end?: Date; @@ -488,430 +245,3 @@ export function fetchProfile(accessToken: string): Promise { "fetchProfile", ); } - -// ─── Field → Measurement mapping ─────────────────────────────── -// The single source of truth is `src/lib/whoop/mapping.md` — keep both in -// sync when adding entries. - -/** Milliseconds → minutes. */ -const MS_TO_MIN = 1 / 60_000; -/** Kilojoules → kilocalories (workout energy). */ -export const KJ_TO_KCAL = 1 / 4.184; - -/** - * A single mapped reading destined for one `Measurement` row. The `source` - * (`WHOOP`) and `externalId` (`:`) are stamped by the - * sync layer (W3); the mapper only emits the type/value/unit/measuredAt and - * the field-tag that disambiguates the several rows derived from one resource. - */ -export interface MappedMeasurement { - type: string; - value: number; - unit: string; - measuredAt: Date; - /** Disambiguator appended to the resource uuid to form the externalId. */ - fieldTag: string; - /** Per-stage sleep rows carry the SleepStage; everything else omits it. */ - sleepStage?: "CORE" | "DEEP" | "REM" | "AWAKE" | "IN_BED"; - /** - * Full externalId for rows the mapper must key itself rather than letting - * the sync layer build `:`. The reconstructed sleep - * segments (one row per synthetic span) need an index in the key so the - * several rows of one stage stay distinct under - * `userId_type_source_externalId`. When set the sync layer uses it verbatim. - */ - externalId?: string; - /** - * `true` on the synthesised per-segment sleep rows whose ORDER is invented: - * WHOOP's v2 API returns only per-stage duration totals (no onset - * timestamps), so the timeline is reconstructed in a fixed physiological - * order. The night is flagged so the UI labels it as an approximate layout - * and never presents it as measured stage timing. - */ - reconstructed?: boolean; -} - -/** - * Every field-tag `mapRecovery` can emit. A recovery's rows are keyed - * `:` and share the `:` prefix with the rows of - * the sleep it belongs to, so a recovery deletion must name these exact ids - * rather than match on the prefix (which would take the night's sleep rows - * with it). Pinned against the mapper by a test. - */ -export const WHOOP_RECOVERY_FIELD_TAGS = [ - "recovery", - "hrv_rmssd", - "rhr", - "spo2", - "skin_temp", -] as const; - -/** - * Map one WHOOP recovery record into Measurement readings. An unscored record - * (`score === null`) yields nothing. `recovery.score.recovery_score` → - * `RECOVERY_SCORE` (source WHOOP, distinct from the COMPUTED proxy); - * `hrv_rmssd_milli` → `HRV_RMSSD` (distinct from the SDNN - * `HEART_RATE_VARIABILITY`); RHR / SpO2 / skin-temp resolve through the - * cross-source picker. - */ -export function mapRecovery(r: WhoopRecovery): MappedMeasurement[] { - if (!r.score) return []; - const measuredAt = new Date(r.updated_at); - const out: MappedMeasurement[] = [ - { - type: "RECOVERY_SCORE", - value: importValue(r.score.recovery_score), - unit: "score", - measuredAt, - fieldTag: "recovery", - }, - { - type: "HRV_RMSSD", - value: importValue(r.score.hrv_rmssd_milli), - unit: "ms", - measuredAt, - fieldTag: "hrv_rmssd", - }, - { - type: "RESTING_HEART_RATE", - value: importValue(r.score.resting_heart_rate), - unit: "bpm", - measuredAt, - fieldTag: "rhr", - }, - ]; - if (typeof r.score.spo2_percentage === "number") { - out.push({ - type: "OXYGEN_SATURATION", - value: importValue(r.score.spo2_percentage), - unit: "%", - measuredAt, - fieldTag: "spo2", - }); - } - if (typeof r.score.skin_temp_celsius === "number") { - out.push({ - type: "SKIN_TEMPERATURE", - value: importValue(r.score.skin_temp_celsius), - unit: "celsius", - measuredAt, - fieldTag: "skin_temp", - }); - } - return out; -} - -/** - * Map one WHOOP sleep record into Measurement readings: per-stage - * `SLEEP_DURATION` rows (ms→min, one per stage), `SLEEP_NEED` (ms→min, summed - * components), the `SLEEP_*` percentage scores, and `RESPIRATORY_RATE`. - */ -export function mapSleep(s: WhoopSleep): MappedMeasurement[] { - if (!s.score) return []; - const measuredAt = new Date(s.end); - const out: MappedMeasurement[] = []; - - const stages = s.score.stage_summary; - - // WHOOP v2 returns only per-stage DURATION totals (no per-stage onset - // timestamps, no hypnogram endpoint). Stamping every stage total on the one - // sleep-END instant makes the hypnogram reconstruct five spans that all touch - // the night's right edge (the stacked-bar artefact). Since the API never - // carries an order, we RECONSTRUCT an ordered, contiguous timeline: lay the - // asleep stages back-to-back from sleep ONSET in a fixed physiological order, - // emitting one timed row per segment with `measuredAt = that segment's END`. - // The ORDER is synthetic, so every reconstructed row is flagged - // `reconstructed: true` and the night DTO advertises it — the UI labels it as - // an approximate layout and never presents it as measured stage timing. - // - // IN_BED stays a single envelope row spanning the whole [start, end] window: - // the in-bed reader wants the union envelope, not a placed segment. AWAKE is - // laid as a leading segment from onset so the asleep stages still partition - // the remaining window. - const onset = new Date(s.start).getTime(); - // Order the laid-out stages: a brief AWAKE settling-in block, then the asleep - // stages CORE → DEEP → REM. Each carries its own duration; the shared builder - // walks the cursor forward so the spans are contiguous and non-overlapping, - // and lays the IN_BED envelope over the whole window. The order is synthetic - // (WHOOP gives no per-stage onset), so every laid segment is flagged - // reconstructed; the same algorithm backs Polar's `mapSleep`. - out.push( - ...reconstructContiguousSleepTimeline({ - startMs: onset, - stages: [ - { - durationMs: stages.total_awake_time_milli, - stage: "AWAKE", - fieldTag: "sleep_awake", - }, - { - durationMs: stages.total_light_sleep_time_milli, - stage: "CORE", - fieldTag: "sleep_core", - }, - { - durationMs: stages.total_slow_wave_sleep_time_milli, - stage: "DEEP", - fieldTag: "sleep_deep", - }, - { - durationMs: stages.total_rem_sleep_time_milli, - stage: "REM", - fieldTag: "sleep_rem", - }, - ], - // IN_BED — single envelope row over the whole sleep window. `measuredAt` - // is the sleep END so `segmentOf` resolves the span back to [start, end]; - // the in-bed union-envelope reader consumes the full window. - inBed: { - durationMs: stages.total_in_bed_time_milli, - measuredAt, - fieldTag: "sleep_in_bed", - }, - // The sync layer keys reconstructed rows by this externalId verbatim; - // the stage tag keeps the several rows of one night distinct under - // userId_type_source_externalId (one segment per stage, no positional - // index — an index renumbered on a re-score when a stage's duration - // flipped 0↔positive, minting fresh ids that double-counted the night). - externalIdFor: (tag) => `${s.id}:seg:${tag}`, - }), - ); - - const need = s.score.sleep_needed; - const totalNeedMilli = - need.baseline_milli + - need.need_from_sleep_debt_milli + - need.need_from_recent_strain_milli + - need.need_from_recent_nap_milli; - out.push({ - type: "SLEEP_NEED", - value: importValue(totalNeedMilli * MS_TO_MIN), - unit: "minutes", - measuredAt, - fieldTag: "sleep_need", - }); - - const pct: Array<[string, number | undefined, string]> = [ - ["SLEEP_PERFORMANCE", s.score.sleep_performance_percentage, "sleep_perf"], - ["SLEEP_EFFICIENCY", s.score.sleep_efficiency_percentage, "sleep_eff"], - [ - "SLEEP_CONSISTENCY", - s.score.sleep_consistency_percentage, - "sleep_consistency", - ], - ]; - for (const [type, value, fieldTag] of pct) { - if (typeof value === "number") { - out.push({ - type, - value: importValue(value), - unit: "%", - measuredAt, - fieldTag, - }); - } - } - - if (typeof s.score.respiratory_rate === "number") { - out.push({ - type: "RESPIRATORY_RATE", - value: importValue(s.score.respiratory_rate), - unit: "breaths/min", - measuredAt, - fieldTag: "resp_rate", - }); - } - - if (typeof stages.disturbance_count === "number") { - out.push({ - type: "SLEEP_DISTURBANCE_COUNT", - value: stages.disturbance_count, - unit: "count", - measuredAt, - fieldTag: "disturbances", - }); - } - - return out; -} - -/** - * Map one WHOOP cycle (day) record: `DAY_STRAIN` (distinct from the COMPUTED - * `STRAIN_SCORE`), `ENERGY_EXPENDITURE_KJ` (kept in native kJ), and the day's - * whole-cycle `AVERAGE_HEART_RATE` / `MAX_HEART_RATE`. Energy is NOT converted - * to kcal here — that conversion is for the workout path only. - */ -export function mapCycle(c: WhoopCycle): MappedMeasurement[] { - if (!c.score) return []; - const measuredAt = new Date(c.start); - const out: MappedMeasurement[] = [ - { - type: "DAY_STRAIN", - value: importValue(c.score.strain), - unit: "score", - measuredAt, - fieldTag: "day_strain", - }, - { - type: "ENERGY_EXPENDITURE_KJ", - value: importValue(c.score.kilojoule), - unit: "kJ", - measuredAt, - fieldTag: "energy_kj", - }, - ]; - - // v1.12.8 — the cycle score's average / max heart rate were fetched but - // dropped before this release. Distinct fieldTags keep them from colliding - // with the strain / energy rows under the shared cycle `externalId`. - if (typeof c.score.average_heart_rate === "number") { - out.push({ - type: "AVERAGE_HEART_RATE", - value: Math.round(c.score.average_heart_rate), - unit: "bpm", - measuredAt, - fieldTag: "avg_hr", - }); - } - if (typeof c.score.max_heart_rate === "number") { - out.push({ - type: "MAX_HEART_RATE", - value: Math.round(c.score.max_heart_rate), - unit: "bpm", - measuredAt, - fieldTag: "max_hr", - }); - } - - return out; -} - -/** Metres → centimetres (WHOOP `height_meter` → `User.heightCm`). */ -const M_TO_CM = 100; - -/** - * The three destinations a WHOOP body-measurement object fans out to. Unlike - * the collection mappers it does NOT emit `MappedMeasurement[]` directly, - * because only `weight` lands in `Measurement` — `maxHeartRate` is a profile - * constant on `WhoopConnection` and `heightCm` is a one-time `User` profile - * seed (written only when the user has no height yet). The sync layer routes - * each piece to its own table. - */ -export interface MappedBodyMeasurement { - /** Self-reported profile weight in kg, or null when WHOOP omits it. */ - weightKg: number | null; - /** Profile max heart rate in bpm, or null when WHOOP omits it. */ - maxHeartRate: number | null; - /** Profile height converted m→cm, or null when WHOOP omits it. */ - heightCm: number | null; -} - -/** - * Map a WHOOP body-measurement object onto its three destinations. The body - * measurement is a single self-reported profile value, not a timestamped - * reading, so the sync layer stamps the weight row's `measuredAt` with the - * fetch time. Every field is optional on the wire — an absent field maps to - * null and the sync layer skips it. - */ -export function mapBody(b: WhoopBodyMeasurement): MappedBodyMeasurement { - // WHOOP body fields are self-reported profile data. Guard against absent, - // non-finite (NaN/Infinity), or non-positive values so a garbage reading - // never seeds a real WEIGHT measurement or a `User.heightCm` of 0 - // (v1.11.3 QA L1). - const positive = (n: number | undefined): n is number => - typeof n === "number" && Number.isFinite(n) && n > 0; - return { - weightKg: positive(b.weight_kilogram) - ? importValue(b.weight_kilogram) - : null, - maxHeartRate: positive(b.max_heart_rate) - ? Math.round(b.max_heart_rate) - : null, - heightCm: positive(b.height_meter) - ? importValue(b.height_meter * M_TO_CM) - : null, - }; -} - -/** - * Field→Measurement mapping table (mirror of `mapping.md`). Documents which - * WHOOP source field becomes which MeasurementType + unit. Used as the - * single-glance reference and by the mapper tests; the mappers above are the - * executable form. - */ -export const WHOOP_FIELD_MAP: Record< - string, - { type: string; unit: string; factor?: number; note?: string } -> = { - "recovery.score.recovery_score": { type: "RECOVERY_SCORE", unit: "score" }, - "recovery.score.hrv_rmssd_milli": { type: "HRV_RMSSD", unit: "ms" }, - "recovery.score.resting_heart_rate": { - type: "RESTING_HEART_RATE", - unit: "bpm", - }, - "recovery.score.spo2_percentage": { - type: "OXYGEN_SATURATION", - unit: "%", - }, - "recovery.score.skin_temp_celsius": { - type: "SKIN_TEMPERATURE", - unit: "celsius", - }, - "sleep.score.stage_summary.*_time_milli": { - type: "SLEEP_DURATION", - unit: "minutes", - factor: MS_TO_MIN, - note: "one row per stage (light→CORE, slow-wave→DEEP, rem→REM, awake→AWAKE, in-bed→IN_BED)", - }, - "sleep.score.sleep_needed.*_milli": { - type: "SLEEP_NEED", - unit: "minutes", - factor: MS_TO_MIN, - note: "baseline + debt + strain + nap components summed", - }, - "sleep.score.sleep_performance_percentage": { - type: "SLEEP_PERFORMANCE", - unit: "%", - }, - "sleep.score.sleep_efficiency_percentage": { - type: "SLEEP_EFFICIENCY", - unit: "%", - }, - "sleep.score.sleep_consistency_percentage": { - type: "SLEEP_CONSISTENCY", - unit: "%", - }, - "sleep.score.respiratory_rate": { - type: "RESPIRATORY_RATE", - unit: "breaths/min", - }, - "cycle.score.strain": { type: "DAY_STRAIN", unit: "score" }, - "cycle.score.kilojoule": { type: "ENERGY_EXPENDITURE_KJ", unit: "kJ" }, - "workout.score.strain": { - type: "WORKOUT_STRAIN", - unit: "score", - note: "preferentially stored in Workout.metadata, not a free-floating Measurement", - }, - "workout.score.kilojoule": { - type: "Workout.totalEnergyKcal", - unit: "kcal", - factor: KJ_TO_KCAL, - note: "kJ→kcal for the workout row", - }, - "body.weight_kilogram": { - type: "WEIGHT", - unit: "kg", - note: "picker ranks a real scale above WHOOP", - }, - "body.max_heart_rate": { - type: "WhoopConnection.maxHeartRate", - unit: "bpm", - note: "profile constant — stored on the connection, not a Measurement", - }, - "body.height_meter": { - type: "User.heightCm", - unit: "cm", - factor: M_TO_CM, - note: "profile seed — written to User.heightCm only when it is null, never as a Measurement", - }, -}; diff --git a/src/lib/whoop/credentials.ts b/src/lib/whoop/credentials.ts index cdb5f547d..b45a1c77a 100644 --- a/src/lib/whoop/credentials.ts +++ b/src/lib/whoop/credentials.ts @@ -9,7 +9,7 @@ */ import { prisma } from "@/lib/db"; import { decrypt } from "@/lib/crypto"; -import type { WhoopCredentials } from "./client"; +import type { WhoopCredentials } from "./oauth"; /** * Fetch and decrypt the user's WHOOP API credentials. diff --git a/src/lib/whoop/map.ts b/src/lib/whoop/map.ts new file mode 100644 index 000000000..3c9d441a9 --- /dev/null +++ b/src/lib/whoop/map.ts @@ -0,0 +1,441 @@ +/** + * WHOOP records to measurement rows: pure functions over the wire shapes + * (`./wire-types.ts`), with no I/O, and the field map kept in sync with + * `./mapping.md`. `__tests__/map-fixtures.test.ts` pins what each makes of + * sanitised fixtures. + */ +import { importValue } from "@/lib/integrations/import-value"; +import type { + WhoopRecovery, + WhoopSleep, + WhoopCycle, + WhoopBodyMeasurement, +} from "./wire-types"; +import { reconstructContiguousSleepTimeline } from "@/lib/sleep/reconstruct-timeline"; + +// ─── Field → Measurement mapping ─────────────────────────────── +// The single source of truth is `src/lib/whoop/mapping.md` — keep both in +// sync when adding entries. + +/** Milliseconds → minutes. */ +const MS_TO_MIN = 1 / 60_000; + +/** Kilojoules → kilocalories (workout energy). */ +export const KJ_TO_KCAL = 1 / 4.184; + +/** + * A single mapped reading destined for one `Measurement` row. The `source` + * (`WHOOP`) and `externalId` (`:`) are stamped by the + * sync layer (W3); the mapper only emits the type/value/unit/measuredAt and + * the field-tag that disambiguates the several rows derived from one resource. + */ +export interface MappedMeasurement { + type: string; + value: number; + unit: string; + measuredAt: Date; + /** Disambiguator appended to the resource uuid to form the externalId. */ + fieldTag: string; + /** Per-stage sleep rows carry the SleepStage; everything else omits it. */ + sleepStage?: "CORE" | "DEEP" | "REM" | "AWAKE" | "IN_BED"; + /** + * Full externalId for rows the mapper must key itself rather than letting + * the sync layer build `:`. The reconstructed sleep + * segments (one row per synthetic span) need an index in the key so the + * several rows of one stage stay distinct under + * `userId_type_source_externalId`. When set the sync layer uses it verbatim. + */ + externalId?: string; + /** + * `true` on the synthesised per-segment sleep rows whose ORDER is invented: + * WHOOP's v2 API returns only per-stage duration totals (no onset + * timestamps), so the timeline is reconstructed in a fixed physiological + * order. The night is flagged so the UI labels it as an approximate layout + * and never presents it as measured stage timing. + */ + reconstructed?: boolean; +} + +/** + * Every field-tag `mapRecovery` can emit. A recovery's rows are keyed + * `:` and share the `:` prefix with the rows of + * the sleep it belongs to, so a recovery deletion must name these exact ids + * rather than match on the prefix (which would take the night's sleep rows + * with it). Pinned against the mapper by a test. + */ +export const WHOOP_RECOVERY_FIELD_TAGS = [ + "recovery", + "hrv_rmssd", + "rhr", + "spo2", + "skin_temp", +] as const; + +/** + * Map one WHOOP recovery record into Measurement readings. An unscored record + * (`score === null`) yields nothing. `recovery.score.recovery_score` → + * `RECOVERY_SCORE` (source WHOOP, distinct from the COMPUTED proxy); + * `hrv_rmssd_milli` → `HRV_RMSSD` (distinct from the SDNN + * `HEART_RATE_VARIABILITY`); RHR / SpO2 / skin-temp resolve through the + * cross-source picker. + */ +export function mapRecovery(r: WhoopRecovery): MappedMeasurement[] { + if (!r.score) return []; + const measuredAt = new Date(r.updated_at); + const out: MappedMeasurement[] = [ + { + type: "RECOVERY_SCORE", + value: importValue(r.score.recovery_score), + unit: "score", + measuredAt, + fieldTag: "recovery", + }, + { + type: "HRV_RMSSD", + value: importValue(r.score.hrv_rmssd_milli), + unit: "ms", + measuredAt, + fieldTag: "hrv_rmssd", + }, + { + type: "RESTING_HEART_RATE", + value: importValue(r.score.resting_heart_rate), + unit: "bpm", + measuredAt, + fieldTag: "rhr", + }, + ]; + if (typeof r.score.spo2_percentage === "number") { + out.push({ + type: "OXYGEN_SATURATION", + value: importValue(r.score.spo2_percentage), + unit: "%", + measuredAt, + fieldTag: "spo2", + }); + } + if (typeof r.score.skin_temp_celsius === "number") { + out.push({ + type: "SKIN_TEMPERATURE", + value: importValue(r.score.skin_temp_celsius), + unit: "celsius", + measuredAt, + fieldTag: "skin_temp", + }); + } + return out; +} + +/** + * Map one WHOOP sleep record into Measurement readings: per-stage + * `SLEEP_DURATION` rows (ms→min, one per stage), `SLEEP_NEED` (ms→min, summed + * components), the `SLEEP_*` percentage scores, and `RESPIRATORY_RATE`. + */ +export function mapSleep(s: WhoopSleep): MappedMeasurement[] { + if (!s.score) return []; + const measuredAt = new Date(s.end); + const out: MappedMeasurement[] = []; + + const stages = s.score.stage_summary; + + // WHOOP v2 returns only per-stage DURATION totals (no per-stage onset + // timestamps, no hypnogram endpoint). Stamping every stage total on the one + // sleep-END instant makes the hypnogram reconstruct five spans that all touch + // the night's right edge (the stacked-bar artefact). Since the API never + // carries an order, we RECONSTRUCT an ordered, contiguous timeline: lay the + // asleep stages back-to-back from sleep ONSET in a fixed physiological order, + // emitting one timed row per segment with `measuredAt = that segment's END`. + // The ORDER is synthetic, so every reconstructed row is flagged + // `reconstructed: true` and the night DTO advertises it — the UI labels it as + // an approximate layout and never presents it as measured stage timing. + // + // IN_BED stays a single envelope row spanning the whole [start, end] window: + // the in-bed reader wants the union envelope, not a placed segment. AWAKE is + // laid as a leading segment from onset so the asleep stages still partition + // the remaining window. + const onset = new Date(s.start).getTime(); + // Order the laid-out stages: a brief AWAKE settling-in block, then the asleep + // stages CORE → DEEP → REM. Each carries its own duration; the shared builder + // walks the cursor forward so the spans are contiguous and non-overlapping, + // and lays the IN_BED envelope over the whole window. The order is synthetic + // (WHOOP gives no per-stage onset), so every laid segment is flagged + // reconstructed; the same algorithm backs Polar's `mapSleep`. + out.push( + ...reconstructContiguousSleepTimeline({ + startMs: onset, + stages: [ + { + durationMs: stages.total_awake_time_milli, + stage: "AWAKE", + fieldTag: "sleep_awake", + }, + { + durationMs: stages.total_light_sleep_time_milli, + stage: "CORE", + fieldTag: "sleep_core", + }, + { + durationMs: stages.total_slow_wave_sleep_time_milli, + stage: "DEEP", + fieldTag: "sleep_deep", + }, + { + durationMs: stages.total_rem_sleep_time_milli, + stage: "REM", + fieldTag: "sleep_rem", + }, + ], + // IN_BED — single envelope row over the whole sleep window. `measuredAt` + // is the sleep END so `segmentOf` resolves the span back to [start, end]; + // the in-bed union-envelope reader consumes the full window. + inBed: { + durationMs: stages.total_in_bed_time_milli, + measuredAt, + fieldTag: "sleep_in_bed", + }, + // The sync layer keys reconstructed rows by this externalId verbatim; + // the stage tag keeps the several rows of one night distinct under + // userId_type_source_externalId (one segment per stage, no positional + // index — an index renumbered on a re-score when a stage's duration + // flipped 0↔positive, minting fresh ids that double-counted the night). + externalIdFor: (tag) => `${s.id}:seg:${tag}`, + }), + ); + + const need = s.score.sleep_needed; + const totalNeedMilli = + need.baseline_milli + + need.need_from_sleep_debt_milli + + need.need_from_recent_strain_milli + + need.need_from_recent_nap_milli; + out.push({ + type: "SLEEP_NEED", + value: importValue(totalNeedMilli * MS_TO_MIN), + unit: "minutes", + measuredAt, + fieldTag: "sleep_need", + }); + + const pct: Array<[string, number | undefined, string]> = [ + ["SLEEP_PERFORMANCE", s.score.sleep_performance_percentage, "sleep_perf"], + ["SLEEP_EFFICIENCY", s.score.sleep_efficiency_percentage, "sleep_eff"], + [ + "SLEEP_CONSISTENCY", + s.score.sleep_consistency_percentage, + "sleep_consistency", + ], + ]; + for (const [type, value, fieldTag] of pct) { + if (typeof value === "number") { + out.push({ + type, + value: importValue(value), + unit: "%", + measuredAt, + fieldTag, + }); + } + } + + if (typeof s.score.respiratory_rate === "number") { + out.push({ + type: "RESPIRATORY_RATE", + value: importValue(s.score.respiratory_rate), + unit: "breaths/min", + measuredAt, + fieldTag: "resp_rate", + }); + } + + if (typeof stages.disturbance_count === "number") { + out.push({ + type: "SLEEP_DISTURBANCE_COUNT", + value: stages.disturbance_count, + unit: "count", + measuredAt, + fieldTag: "disturbances", + }); + } + + return out; +} + +/** + * Map one WHOOP cycle (day) record: `DAY_STRAIN` (distinct from the COMPUTED + * `STRAIN_SCORE`), `ENERGY_EXPENDITURE_KJ` (kept in native kJ), and the day's + * whole-cycle `AVERAGE_HEART_RATE` / `MAX_HEART_RATE`. Energy is NOT converted + * to kcal here — that conversion is for the workout path only. + */ +export function mapCycle(c: WhoopCycle): MappedMeasurement[] { + if (!c.score) return []; + const measuredAt = new Date(c.start); + const out: MappedMeasurement[] = [ + { + type: "DAY_STRAIN", + value: importValue(c.score.strain), + unit: "score", + measuredAt, + fieldTag: "day_strain", + }, + { + type: "ENERGY_EXPENDITURE_KJ", + value: importValue(c.score.kilojoule), + unit: "kJ", + measuredAt, + fieldTag: "energy_kj", + }, + ]; + + // The cycle score's average and max heart rate. Distinct fieldTags keep them + // from colliding with the strain and energy rows under the shared cycle + // `externalId`. + if (typeof c.score.average_heart_rate === "number") { + out.push({ + type: "AVERAGE_HEART_RATE", + value: Math.round(c.score.average_heart_rate), + unit: "bpm", + measuredAt, + fieldTag: "avg_hr", + }); + } + if (typeof c.score.max_heart_rate === "number") { + out.push({ + type: "MAX_HEART_RATE", + value: Math.round(c.score.max_heart_rate), + unit: "bpm", + measuredAt, + fieldTag: "max_hr", + }); + } + + return out; +} + +/** Metres → centimetres (WHOOP `height_meter` → `User.heightCm`). */ +const M_TO_CM = 100; + +/** + * The three destinations a WHOOP body-measurement object fans out to. Unlike + * the collection mappers it does NOT emit `MappedMeasurement[]` directly, + * because only `weight` lands in `Measurement` — `maxHeartRate` is a profile + * constant on `WhoopConnection` and `heightCm` is a one-time `User` profile + * seed (written only when the user has no height yet). The sync layer routes + * each piece to its own table. + */ +export interface MappedBodyMeasurement { + /** Self-reported profile weight in kg, or null when WHOOP omits it. */ + weightKg: number | null; + /** Profile max heart rate in bpm, or null when WHOOP omits it. */ + maxHeartRate: number | null; + /** Profile height converted m→cm, or null when WHOOP omits it. */ + heightCm: number | null; +} + +/** + * Map a WHOOP body-measurement object onto its three destinations. The body + * measurement is a single self-reported profile value, not a timestamped + * reading, so the sync layer stamps the weight row's `measuredAt` with the + * fetch time. Every field is optional on the wire — an absent field maps to + * null and the sync layer skips it. + */ +export function mapBody(b: WhoopBodyMeasurement): MappedBodyMeasurement { + // WHOOP body fields are self-reported profile data. Guard against absent, + // non-finite (NaN/Infinity), or non-positive values so a garbage reading + // never seeds a real WEIGHT measurement or a `User.heightCm` of 0. + const positive = (n: number | undefined): n is number => + typeof n === "number" && Number.isFinite(n) && n > 0; + return { + weightKg: positive(b.weight_kilogram) + ? importValue(b.weight_kilogram) + : null, + maxHeartRate: positive(b.max_heart_rate) + ? Math.round(b.max_heart_rate) + : null, + heightCm: positive(b.height_meter) + ? importValue(b.height_meter * M_TO_CM) + : null, + }; +} + +/** + * Field→Measurement mapping table (mirror of `mapping.md`). Documents which + * WHOOP source field becomes which MeasurementType + unit. Used as the + * single-glance reference and by the mapper tests; the mappers above are the + * executable form. + */ +export const WHOOP_FIELD_MAP: Record< + string, + { type: string; unit: string; factor?: number; note?: string } +> = { + "recovery.score.recovery_score": { type: "RECOVERY_SCORE", unit: "score" }, + "recovery.score.hrv_rmssd_milli": { type: "HRV_RMSSD", unit: "ms" }, + "recovery.score.resting_heart_rate": { + type: "RESTING_HEART_RATE", + unit: "bpm", + }, + "recovery.score.spo2_percentage": { + type: "OXYGEN_SATURATION", + unit: "%", + }, + "recovery.score.skin_temp_celsius": { + type: "SKIN_TEMPERATURE", + unit: "celsius", + }, + "sleep.score.stage_summary.*_time_milli": { + type: "SLEEP_DURATION", + unit: "minutes", + factor: MS_TO_MIN, + note: "one row per stage (light→CORE, slow-wave→DEEP, rem→REM, awake→AWAKE, in-bed→IN_BED)", + }, + "sleep.score.sleep_needed.*_milli": { + type: "SLEEP_NEED", + unit: "minutes", + factor: MS_TO_MIN, + note: "baseline + debt + strain + nap components summed", + }, + "sleep.score.sleep_performance_percentage": { + type: "SLEEP_PERFORMANCE", + unit: "%", + }, + "sleep.score.sleep_efficiency_percentage": { + type: "SLEEP_EFFICIENCY", + unit: "%", + }, + "sleep.score.sleep_consistency_percentage": { + type: "SLEEP_CONSISTENCY", + unit: "%", + }, + "sleep.score.respiratory_rate": { + type: "RESPIRATORY_RATE", + unit: "breaths/min", + }, + "cycle.score.strain": { type: "DAY_STRAIN", unit: "score" }, + "cycle.score.kilojoule": { type: "ENERGY_EXPENDITURE_KJ", unit: "kJ" }, + "workout.score.strain": { + type: "WORKOUT_STRAIN", + unit: "score", + note: "preferentially stored in Workout.metadata, not a free-floating Measurement", + }, + "workout.score.kilojoule": { + type: "Workout.totalEnergyKcal", + unit: "kcal", + factor: KJ_TO_KCAL, + note: "kJ→kcal for the workout row", + }, + "body.weight_kilogram": { + type: "WEIGHT", + unit: "kg", + note: "picker ranks a real scale above WHOOP", + }, + "body.max_heart_rate": { + type: "WhoopConnection.maxHeartRate", + unit: "bpm", + note: "profile constant — stored on the connection, not a Measurement", + }, + "body.height_meter": { + type: "User.heightCm", + unit: "cm", + factor: M_TO_CM, + note: "profile seed — written to User.heightCm only when it is null, never as a Measurement", + }, +}; diff --git a/src/lib/whoop/oauth-descriptor.ts b/src/lib/whoop/oauth-descriptor.ts index 8a86ac2a4..d3fdfc400 100644 --- a/src/lib/whoop/oauth-descriptor.ts +++ b/src/lib/whoop/oauth-descriptor.ts @@ -7,7 +7,7 @@ * WHOOP user id is a connect still in flight and has no usable grant yet. */ import type { OAuthTokenDescriptor } from "@/lib/integrations/oauth-kit/token"; -import { refreshAccessToken, type WhoopCredentials } from "./client"; +import { type WhoopCredentials, whoopOAuth } from "./oauth"; import { getUserWhoopCredentials } from "./credentials"; /** Refresh this long before the stored expiry. */ @@ -47,5 +47,5 @@ export const whoopTokenDescriptor: OAuthTokenDescriptor = { }, loadCredentials: (userId) => getUserWhoopCredentials(userId), refresh: (refreshToken, credentials) => - refreshAccessToken(refreshToken, credentials), + whoopOAuth.refresh(refreshToken, credentials), }; diff --git a/src/lib/whoop/oauth.ts b/src/lib/whoop/oauth.ts new file mode 100644 index 000000000..17107027a --- /dev/null +++ b/src/lib/whoop/oauth.ts @@ -0,0 +1,54 @@ +/** + * The WHOOP OAuth client: consent URL, code exchange and refresh, over the + * shared factory (`integrations/oauth-kit/client.ts`). + * + * Token TTL is 3600 s and refresh tokens rotate: each refresh invalidates the + * prior access and refresh token, so the token lifecycle persists both. The + * `offline` scope is what returns a refresh token at all, and WHOOP wants it + * re-sent on the refresh. + */ +import { + defineOAuthClient, + type OAuthClientCredentials, +} from "@/lib/integrations/oauth-kit/client"; +import { derivedRedirectUri } from "@/lib/integrations/oauth-kit/redirect-uri"; + +import { WhoopApiError } from "./response-classifier"; + +export type WhoopCredentials = OAuthClientCredentials; + +/** + * Space-separated on the wire. `offline` is mandatory for a refresh token; + * the rest are the read-only collection scopes, all requested so the person + * grants once. + */ +export const WHOOP_OAUTH_SCOPE = + "offline read:recovery read:sleep read:workout read:cycles read:profile read:body_measurement" as const; + +export interface WhoopTokenResponse { + access_token: string; + refresh_token: string; + expires_in: number; + scope?: string; + token_type?: string; +} + +export const whoopOAuth = defineOAuthClient({ + service: "whoop", + authorizeUrl: "https://api.prod.whoop.com/oauth/oauth2/auth", + tokenUrl: "https://api.prod.whoop.com/oauth/oauth2/token", + redirectUri: () => + derivedRedirectUri("WHOOP_REDIRECT_URI", "/api/whoop/callback"), + authorizeParams: () => ({ scope: WHOOP_OAUTH_SCOPE }), + clientAuth: { kind: "form" }, + extraFields: { refresh: { scope: "offline" } }, + refresh: true, + error: (failure) => + new WhoopApiError({ + verb: failure.verb, + classification: failure.classification, + httpStatus: failure.status, + reason: failure.reason, + upstreamError: failure.upstreamError, + }), +}); diff --git a/src/lib/whoop/sync-body.ts b/src/lib/whoop/sync-body.ts index 5a3ca040a..cfdabaa54 100644 --- a/src/lib/whoop/sync-body.ts +++ b/src/lib/whoop/sync-body.ts @@ -20,7 +20,8 @@ * 403 soft-skips this data class (returns 0, leaves the connection connected) * rather than parking the whole integration — see `isCollectionForbidden`. */ -import { fetchBodyMeasurement, mapBody } from "./client"; +import { fetchBodyMeasurement } from "./client"; +import { mapBody } from "./map"; import { getValidToken, handleCollectionFetchError, diff --git a/src/lib/whoop/sync-core.ts b/src/lib/whoop/sync-core.ts index 48f88adcb..86bf44443 100644 --- a/src/lib/whoop/sync-core.ts +++ b/src/lib/whoop/sync-core.ts @@ -34,14 +34,10 @@ import { type FailureKind, } from "@/lib/integrations/status"; import { - collapseToTypeDayKeys, - recomputeBucketsForKeys, -} from "@/lib/rollups/measurement-rollups"; -import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; -import { - emitInsertedMeasurementArrivals, - type InsertedMeasurementArrivalRow, -} from "@/lib/arrivals/measurement-emit"; + afterProviderWrite, + invalidateProviderStatus, +} from "@/lib/integrations/sync-kit/after-write"; +import type { InsertedMeasurementArrivalRow } from "@/lib/arrivals/measurement-emit"; import { obtainAccessToken } from "@/lib/integrations/oauth-kit/token"; import { MeasurementReconciliationError, @@ -351,17 +347,8 @@ export async function upsertWhoopMeasurements( let imported = 0; const touchedTypes = new Set(); - const invalidateTouchedTypes = (): void => { - if (touchedTypes.size === 0) return; - - void invalidateStatusInsightsForTypes(userId, [...touchedTypes], { - source: "sync", - }).catch((err) => { - getEvent()?.addWarning( - `whoop: status-insight invalidate failed for ${userId}: ${err}`, - ); - }); - }; + const invalidateTouchedTypes = (): void => + invalidateProviderStatus(userId, "whoop", touchedTypes); for ( let chunkStart = 0; @@ -431,17 +418,15 @@ export async function upsertWhoopMeasurements( } opts.onInserted?.(insertedRows); - void emitInsertedMeasurementArrivals(userId, insertedRows, "whoop").catch( - () => {}, - ); - try { - const keys = collapseToTypeDayKeys(touched); - await recomputeBucketsForKeys(userId, keys); - } catch (err) { - getEvent()?.addWarning( - `whoop: rollup recompute failed for ${userId}: ${err}`, - ); - } + // Each chunk folds its own days; the status cards are re-warmed once, + // after the last chunk (or the one that failed). + await afterProviderWrite({ + userId, + provider: "whoop", + touched, + arrivals: { rows: insertedRows, source: "whoop" }, + invalidateStatus: false, + }); } invalidateTouchedTypes(); diff --git a/src/lib/whoop/sync-cycle.ts b/src/lib/whoop/sync-cycle.ts index 39c0488ad..b6f635649 100644 --- a/src/lib/whoop/sync-cycle.ts +++ b/src/lib/whoop/sync-cycle.ts @@ -5,7 +5,8 @@ * * Cycle id is an int64 (not a UUID); externalId = `cycle::`. */ -import { fetchCycles, mapCycle } from "./client"; +import { fetchCycles } from "./client"; +import { mapCycle } from "./map"; import { getValidToken, incrementalStart, diff --git a/src/lib/whoop/sync-recovery.ts b/src/lib/whoop/sync-recovery.ts index 496371ed7..24014d8b2 100644 --- a/src/lib/whoop/sync-recovery.ts +++ b/src/lib/whoop/sync-recovery.ts @@ -13,8 +13,8 @@ import { fetchRecoveries, fetchRecoveryByCycleId, fetchSleepById, - mapRecovery, } from "./client"; +import { mapRecovery } from "./map"; import { getValidToken, incrementalStart, diff --git a/src/lib/whoop/sync-sleep.ts b/src/lib/whoop/sync-sleep.ts index 3adf29ad5..7bbb34ad3 100644 --- a/src/lib/whoop/sync-sleep.ts +++ b/src/lib/whoop/sync-sleep.ts @@ -27,7 +27,8 @@ import { } from "@/lib/sleep/sweep-stale-segments"; import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; -import { fetchSleeps, fetchSleepById, mapSleep } from "./client"; +import { fetchSleeps, fetchSleepById } from "./client"; +import { mapSleep } from "./map"; import { getValidToken, incrementalStart, diff --git a/src/lib/whoop/sync-workout.ts b/src/lib/whoop/sync-workout.ts index 046e38d33..2fe65cefb 100644 --- a/src/lib/whoop/sync-workout.ts +++ b/src/lib/whoop/sync-workout.ts @@ -19,12 +19,9 @@ * source's. Raw `sport_id` / `sport_name` still live in `Workout.metadata` * for traceability. */ -import { - fetchWorkouts, - fetchWorkoutById, - KJ_TO_KCAL, - type WhoopWorkout, -} from "./client"; +import { fetchWorkouts, fetchWorkoutById } from "./client"; +import { KJ_TO_KCAL } from "./map"; +import { type WhoopWorkout } from "./wire-types"; import { getValidToken, incrementalStart, diff --git a/src/lib/whoop/webhook-handler.ts b/src/lib/whoop/webhook-handler.ts index a815cabba..63cdbc3f1 100644 --- a/src/lib/whoop/webhook-handler.ts +++ b/src/lib/whoop/webhook-handler.ts @@ -33,7 +33,12 @@ import { annotate, getEvent } from "@/lib/logging/context"; import { enqueueWhenBossReady } from "@/lib/jobs/boss-ready"; import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; import type { MeasurementType, Prisma } from "@/generated/prisma/client"; -import { WHOOP_RECOVERY_FIELD_TAGS } from "./client"; +import { WHOOP_RECOVERY_FIELD_TAGS } from "./map"; +import { + WHOOP_RECOVERY_SYNC_QUEUE, + WHOOP_SLEEP_SYNC_QUEUE, + WHOOP_WORKOUT_SYNC_QUEUE, +} from "@/lib/jobs/integration-poll-queues"; /** * Maximum age of a webhook timestamp before it is treated as a replay. Five @@ -48,9 +53,9 @@ const WHOOP_SIGNATURE_MAX_AGE_MS = 5 * 60 * 1000; * webhook (poll-only) so it is intentionally absent. */ const WHOOP_RESOURCE_QUEUE: Record = { - recovery: "whoop-recovery-sync", - sleep: "whoop-sleep-sync", - workout: "whoop-workout-sync", + recovery: WHOOP_RECOVERY_SYNC_QUEUE, + sleep: WHOOP_SLEEP_SYNC_QUEUE, + workout: WHOOP_WORKOUT_SYNC_QUEUE, }; /** diff --git a/src/lib/whoop/wire-types.ts b/src/lib/whoop/wire-types.ts new file mode 100644 index 000000000..2e575e1e7 --- /dev/null +++ b/src/lib/whoop/wire-types.ts @@ -0,0 +1,124 @@ +/** + * The WHOOP API v2 wire shapes HealthLog reads: the collection envelope and + * one interface per record. Shared by the endpoint fetchers (`./client.ts`) + * and the mappers (`./map.ts`). + */ +/** WHOOP paginated collection envelope: `{ records, next_token }`. */ +export interface WhoopCollection { + records: T[]; + next_token?: string | null; +} + +/** Recovery record. `score` is null until WHOOP finishes scoring. */ +export interface WhoopRecovery { + cycle_id: number; + sleep_id: string; + user_id: number; + created_at: string; + updated_at: string; + score_state: string; + score: { + user_calibrating: boolean; + recovery_score: number; + resting_heart_rate: number; + hrv_rmssd_milli: number; + spo2_percentage?: number; + skin_temp_celsius?: number; + } | null; +} + +/** Sleep activity record. */ +export interface WhoopSleep { + id: string; + /** + * The cycle this sleep belongs to. A v2 recovery is read through its cycle, + * and the recovery webhook carries the SLEEP id, so the targeted recovery + * refresh resolves sleep -> cycle -> recovery through this field. + */ + cycle_id?: number; + user_id: number; + created_at: string; + updated_at: string; + start: string; + end: string; + nap: boolean; + score_state: string; + score: { + stage_summary: { + total_in_bed_time_milli: number; + total_awake_time_milli: number; + total_light_sleep_time_milli: number; + total_slow_wave_sleep_time_milli: number; + total_rem_sleep_time_milli: number; + // Per-night sleep disturbance tally. Optional: older or still-scoring + // records may omit it. + disturbance_count?: number; + }; + sleep_needed: { + baseline_milli: number; + need_from_sleep_debt_milli: number; + need_from_recent_strain_milli: number; + need_from_recent_nap_milli: number; + }; + respiratory_rate?: number; + sleep_performance_percentage?: number; + sleep_consistency_percentage?: number; + sleep_efficiency_percentage?: number; + } | null; +} + +/** Physiological cycle (day) record. `id` is an int64. */ +export interface WhoopCycle { + id: number; + user_id: number; + created_at: string; + updated_at: string; + start: string; + end?: string | null; + score_state: string; + score: { + strain: number; + kilojoule: number; + average_heart_rate: number; + max_heart_rate: number; + } | null; +} + +/** Workout activity record. */ +export interface WhoopWorkout { + id: string; + user_id: number; + created_at: string; + updated_at: string; + start: string; + end: string; + sport_id?: number; + sport_name?: string; + score_state: string; + score: { + strain: number; + average_heart_rate: number; + max_heart_rate: number; + kilojoule: number; + percent_recorded: number; + distance_meter?: number; + altitude_gain_meter?: number; + altitude_change_meter?: number; + zone_durations?: Record; + } | null; +} + +/** Body-measurement object (single, not paginated). */ +export interface WhoopBodyMeasurement { + height_meter?: number; + weight_kilogram?: number; + max_heart_rate?: number; +} + +/** Basic profile object (single, not paginated). */ +export interface WhoopProfile { + user_id: number; + email?: string; + first_name?: string; + last_name?: string; +} diff --git a/src/lib/withings/__tests__/client.test.ts b/src/lib/withings/__tests__/client.test.ts index e3525455e..27e63d2ca 100644 --- a/src/lib/withings/__tests__/client.test.ts +++ b/src/lib/withings/__tests__/client.test.ts @@ -1,16 +1,17 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { MEASURE_TYPE_MAP, - WITHINGS_OAUTH_SCOPE, - exchangeCode, fetchMeasurements, - hasActivityScope, - parseWithingsScope, - refreshAccessToken, subscribeWebhook, } from "../client"; +import { + WITHINGS_OAUTH_SCOPE, + hasActivityScope, + parseWithingsScope, +} from "@/lib/withings/oauth"; import { WithingsApiError } from "../response-classifier"; import { WITHINGS_NOTIFY_APPLIS } from "../sync"; +import { withingsOAuth } from "../oauth"; /** * Withings client — meastype mapping + payload-parsing unit tests. @@ -400,7 +401,7 @@ describe("client off-response handling — refreshAccessToken", () => { })), ); - await expect(refreshAccessToken("rt", creds)).rejects.toMatchObject({ + await expect(withingsOAuth.refresh("rt", creds)).rejects.toMatchObject({ name: "WithingsApiError", classification: "reauth_required", withingsStatus: 101, @@ -416,7 +417,7 @@ describe("client off-response handling — refreshAccessToken", () => { })), ); - const err = await refreshAccessToken("rt", creds).catch((e) => e); + const err = await withingsOAuth.refresh("rt", creds).catch((e) => e); expect(err).toBeInstanceOf(WithingsApiError); expect((err as WithingsApiError).classification).toBe("transient"); expect((err as WithingsApiError).reason).toBe("http_503"); @@ -434,7 +435,9 @@ describe("client off-response handling — exchangeCode", () => { json: async () => ({ status: 100, error: "Authentication failed" }), })), ); - const err = await exchangeCode("auth-code", creds).catch((e) => e); + const err = await withingsOAuth + .exchange("auth-code", creds) + .catch((e) => e); expect(err).toBeInstanceOf(WithingsApiError); expect((err as WithingsApiError).classification).toBe("reauth_required"); }); diff --git a/src/lib/withings/__tests__/subscription-state.test.ts b/src/lib/withings/__tests__/subscription-state.test.ts index 4267e6dfd..079a2ca6f 100644 --- a/src/lib/withings/__tests__/subscription-state.test.ts +++ b/src/lib/withings/__tests__/subscription-state.test.ts @@ -31,9 +31,15 @@ vi.mock("@/lib/crypto", () => ({ vi.mock("../client", () => ({ fetchMeasurements: vi.fn(), - refreshAccessToken: vi.fn(), subscribeWebhook: vi.fn(), })); +vi.mock("../oauth", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + withingsOAuth: { ...actual.withingsOAuth, refresh: vi.fn() }, + }; +}); vi.mock("../credentials", () => ({ getUserWithingsCredentials: vi.fn(async () => ({ @@ -64,7 +70,7 @@ vi.mock("@/lib/rollups/measurement-rollups", async () => { }; }); -vi.mock("@/lib/insights/comprehensive-generate", () => ({ +vi.mock("@/lib/insights/status-invalidation", () => ({ invalidateStatusInsightsForTypes: vi.fn().mockResolvedValue(undefined), })); diff --git a/src/lib/withings/__tests__/sync-ecg.test.ts b/src/lib/withings/__tests__/sync-ecg.test.ts index 916677346..807efb3e3 100644 --- a/src/lib/withings/__tests__/sync-ecg.test.ts +++ b/src/lib/withings/__tests__/sync-ecg.test.ts @@ -84,10 +84,6 @@ vi.mock("@/lib/rollups/measurement-rollups", async () => { }; }); -vi.mock("@/lib/insights/comprehensive-generate", () => ({ - invalidateStatusInsightsForTypes: vi.fn().mockResolvedValue(undefined), -})); - import { prisma } from "@/lib/db"; import { isReauthRequired, diff --git a/src/lib/withings/__tests__/sync-measure-watermark.test.ts b/src/lib/withings/__tests__/sync-measure-watermark.test.ts index 4408c931d..ff2317418 100644 --- a/src/lib/withings/__tests__/sync-measure-watermark.test.ts +++ b/src/lib/withings/__tests__/sync-measure-watermark.test.ts @@ -37,10 +37,14 @@ vi.mock("@/lib/crypto", () => ({ vi.mock("../client", () => ({ WITHINGS_MEASTYPES: [1, 9, 10, 11], fetchMeasurements: vi.fn(), - refreshAccessToken: vi.fn(), subscribeWebhook: vi.fn(), })); +vi.mock("../oauth", async (orig) => { + const actual = await orig(); + return { ...actual, withingsOAuth: { refresh: vi.fn() } }; +}); + vi.mock("../credentials", () => ({ getUserWithingsCredentials: vi.fn(async () => ({ clientId: "cid", @@ -70,7 +74,7 @@ vi.mock("@/lib/rollups/measurement-rollups", async () => { }; }); -vi.mock("@/lib/insights/comprehensive-generate", () => ({ +vi.mock("@/lib/insights/status-invalidation", () => ({ invalidateStatusInsightsForTypes: vi.fn().mockResolvedValue(undefined), })); @@ -88,7 +92,8 @@ vi.mock("@/lib/logging/context", () => ({ })); import { prisma } from "@/lib/db"; -import { fetchMeasurements, refreshAccessToken } from "../client"; +import { fetchMeasurements } from "../client"; +import { withingsOAuth } from "../oauth"; import { recordSyncFailure, recordSyncSuccess, @@ -149,7 +154,7 @@ describe("getValidToken — serialized rotating refresh", () => { expect(result?.accessToken).toBe("winner-access"); expect(prisma.$queryRaw).toHaveBeenCalledTimes(1); - expect(refreshAccessToken).not.toHaveBeenCalled(); + expect(withingsOAuth.refresh).not.toHaveBeenCalled(); expect(prisma.$transaction).toHaveBeenCalledWith( expect.any(Function), expect.objectContaining({ @@ -248,11 +253,11 @@ describe("syncUserMeasurements — watermark hold on hard row failure (F4)", () expect(recordSyncFailure).not.toHaveBeenCalled(); }); - // Watched red: with the `invalidateUserMeasurements(userId)` call removed - // from the sync tail this fails — the pre-fix tail evicted only the - // dashboard snapshot, so correlations / analytics / targets / derived - // served pre-sync bodies until their TTL lapsed after every hourly sync - // (the correlations route documents the invariant this restores). + // Without the cache leg of the shared write tail this fails: an earlier + // tail evicted only the dashboard snapshot, so correlations, analytics, + // targets and derived served pre-sync bodies until their TTL lapsed after + // every hourly sync. The caches are marked stale back to the earliest + // reading the sync wrote, so closed blocks of older readings survive. it("marks the per-user measurement caches stale when rows landed", async () => { vi.mocked(fetchMeasurements).mockResolvedValue([ { @@ -268,6 +273,7 @@ describe("syncUserMeasurements — watermark hold on hard row failure (F4)", () await import("@/lib/cache/invalidate"); expect(vi.mocked(invalidateUserMeasurements)).toHaveBeenCalledWith( "user-1", + { touchedFrom: new Date("2026-05-16T08:00:00Z") }, ); }); }); diff --git a/src/lib/withings/__tests__/sync-plan.test.ts b/src/lib/withings/__tests__/sync-plan.test.ts index 1cf80a2a5..e63259e97 100644 --- a/src/lib/withings/__tests__/sync-plan.test.ts +++ b/src/lib/withings/__tests__/sync-plan.test.ts @@ -65,7 +65,7 @@ vi.mock("@/lib/rollups/measurement-rollups", async () => { }; }); -vi.mock("@/lib/insights/comprehensive-generate", () => ({ +vi.mock("@/lib/insights/status-invalidation", () => ({ invalidateStatusInsightsForTypes: vi.fn().mockResolvedValue(undefined), })); diff --git a/src/lib/withings/__tests__/sync-plausibility.test.ts b/src/lib/withings/__tests__/sync-plausibility.test.ts index b80a6715c..60901ebb6 100644 --- a/src/lib/withings/__tests__/sync-plausibility.test.ts +++ b/src/lib/withings/__tests__/sync-plausibility.test.ts @@ -40,10 +40,16 @@ vi.mock("../client", async () => { return { ...actual, fetchMeasurements: vi.fn(), - refreshAccessToken: vi.fn(), subscribeWebhook: vi.fn(), }; }); +vi.mock("../oauth", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + withingsOAuth: { ...actual.withingsOAuth, refresh: vi.fn() }, + }; +}); vi.mock("../credentials", () => ({ getUserWithingsCredentials: vi.fn(async () => ({ @@ -70,7 +76,7 @@ vi.mock("@/lib/rollups/measurement-rollups", async () => { }; }); -vi.mock("@/lib/insights/comprehensive-generate", () => ({ +vi.mock("@/lib/insights/status-invalidation", () => ({ invalidateStatusInsightsForTypes: vi.fn().mockResolvedValue(undefined), })); diff --git a/src/lib/withings/client.ts b/src/lib/withings/client.ts index f5174d02b..c60a34e4a 100644 --- a/src/lib/withings/client.ts +++ b/src/lib/withings/client.ts @@ -1,5 +1,6 @@ /** - * Withings API client for OAuth and data fetching. + * Withings API client: measurement reads and webhook subscriptions. The OAuth + * handshake is `./oauth.ts`. * Docs: https://developer.withings.com/api-reference */ import { getEvent } from "@/lib/logging/context"; @@ -8,184 +9,12 @@ import { WithingsApiError, classifyWithingsResponse, } from "./response-classifier"; -import { envOr, envValue } from "@/lib/env"; import { importValue } from "@/lib/integrations/import-value"; import { walkPages } from "@/lib/integrations/page-walk"; -const WITHINGS_OAUTH_URL = - "https://account.withings.com/oauth2_user/authorize2"; -const WITHINGS_TOKEN_URL = "https://wbsapi.withings.net/v2/oauth2"; const WITHINGS_MEASURE_URL = "https://wbsapi.withings.net/measure"; const WITHINGS_NOTIFY_URL = "https://wbsapi.withings.net/notify"; -export interface WithingsCredentials { - clientId: string; - clientSecret: string; -} - -export function getWithingsRedirectUri(): string { - // An empty or blank value counts as unset: the compose whitelist - // materialises an unset var as an empty string, which must fall through to - // the derived URI rather than go out as `redirect_uri=`. - return envOr( - "WITHINGS_REDIRECT_URI", - `${envValue("NEXT_PUBLIC_APP_URL")}/api/withings/callback`, - ); -} - -/** - * v1.4.25 — Withings OAuth scope set HealthLog requests. - * - * - `user.metrics` : every meastype the Measure / Heart-list / - * Sleep endpoints expose (weight, BP, pulse, - * SpO2, body comp, temperature, VO2 max, …) - * - `user.activity` : steps / active energy / distance / floors — - * served by `POST /v2/measure?action=getactivity`. - * The sync routine itself lands in v1.4.26; the - * scope is requested now so existing users - * reconnect once instead of twice. - * - * v1.4.24-and-earlier connections requested `user.metrics` only — the - * Settings → Integrations card surfaces a reconnect banner for those - * users (see `WithingsConnection.scope IS NULL` or scope without - * `user.activity`). - */ -export const WITHINGS_OAUTH_SCOPE = "user.metrics,user.activity" as const; - -/** - * Parse the persisted scope string into a Set for membership checks. - * Legacy NULL → empty set (treated as "no scopes yet") so the - * reconnect-banner conditional reads as truthy. - */ -export function parseWithingsScope(scope: string | null): Set { - if (!scope) return new Set(); - return new Set( - scope - .split(",") - .map((s) => s.trim()) - .filter(Boolean), - ); -} - -/** - * Returns true when the connection holds `user.activity` — the - * minimum scope required by the Activity / Sleep endpoints. - */ -export function hasActivityScope(scope: string | null): boolean { - return parseWithingsScope(scope).has("user.activity"); -} - -/** - * Generate Withings OAuth authorization URL. - */ -export function getAuthorizationUrl( - state: string, - creds: WithingsCredentials, -): string { - const params = new URLSearchParams({ - response_type: "code", - client_id: creds.clientId, - redirect_uri: getWithingsRedirectUri(), - scope: WITHINGS_OAUTH_SCOPE, - state, - }); - return `${WITHINGS_OAUTH_URL}?${params}`; -} - -export interface WithingsTokenResponse { - userid: string; - access_token: string; - refresh_token: string; - expires_in: number; -} - -/** - * Exchange authorization code for tokens. - */ -export async function exchangeCode( - code: string, - creds: WithingsCredentials, -): Promise { - const params = new URLSearchParams({ - action: "requesttoken", - grant_type: "authorization_code", - client_id: creds.clientId, - client_secret: creds.clientSecret, - redirect_uri: getWithingsRedirectUri(), - code, - }); - - const start = performance.now(); - const res = await safeFetch(WITHINGS_TOKEN_URL, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: params.toString(), - }); - - const json = await res.json(); - const verdict = classifyWithingsResponse(res.status, json); - getEvent()?.addExternalCall({ - service: "withings", - method: "exchangeCode", - duration_ms: Math.round(performance.now() - start), - status: res.status, - error: verdict.classification === "success" ? undefined : verdict.reason, - }); - if (verdict.classification !== "success") { - throw new WithingsApiError({ - verb: "token", - classification: verdict.classification, - withingsStatus: verdict.withingsStatus, - reason: verdict.reason, - upstreamError: typeof json?.error === "string" ? json.error : undefined, - }); - } - return json.body as WithingsTokenResponse; -} - -/** - * Refresh an expired access token. - */ -export async function refreshAccessToken( - refreshToken: string, - creds: WithingsCredentials, -): Promise { - const params = new URLSearchParams({ - action: "requesttoken", - grant_type: "refresh_token", - client_id: creds.clientId, - client_secret: creds.clientSecret, - refresh_token: refreshToken, - }); - - const start = performance.now(); - const res = await safeFetch(WITHINGS_TOKEN_URL, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: params.toString(), - }); - - const json = await res.json(); - const verdict = classifyWithingsResponse(res.status, json); - getEvent()?.addExternalCall({ - service: "withings", - method: "refreshAccessToken", - duration_ms: Math.round(performance.now() - start), - status: res.status, - error: verdict.classification === "success" ? undefined : verdict.reason, - }); - if (verdict.classification !== "success") { - throw new WithingsApiError({ - verb: "refresh", - classification: verdict.classification, - withingsStatus: verdict.withingsStatus, - reason: verdict.reason, - upstreamError: typeof json?.error === "string" ? json.error : undefined, - }); - } - return json.body as WithingsTokenResponse; -} - // Withings measure type mapping. The single source of truth is // `src/lib/withings/mapping.md` — keep both in sync when adding entries. // https://developer.withings.com/api-reference#tag/measure/operation/measure-getmeas diff --git a/src/lib/withings/credentials.ts b/src/lib/withings/credentials.ts index 0f96cce56..f4e9f5659 100644 --- a/src/lib/withings/credentials.ts +++ b/src/lib/withings/credentials.ts @@ -3,7 +3,7 @@ */ import { prisma } from "@/lib/db"; import { decrypt } from "@/lib/crypto"; -import type { WithingsCredentials } from "./client"; +import type { WithingsCredentials } from "./oauth"; /** * Fetch and decrypt the user's Withings API credentials. diff --git a/src/lib/withings/oauth-descriptor.ts b/src/lib/withings/oauth-descriptor.ts index fa99d0f5b..ac57e124a 100644 --- a/src/lib/withings/oauth-descriptor.ts +++ b/src/lib/withings/oauth-descriptor.ts @@ -6,7 +6,7 @@ * ahead of it, and rotates the refresh token on every use. */ import type { OAuthTokenDescriptor } from "@/lib/integrations/oauth-kit/token"; -import { refreshAccessToken, type WithingsCredentials } from "./client"; +import { type WithingsCredentials, withingsOAuth } from "./oauth"; import { getUserWithingsCredentials } from "./credentials"; /** Refresh this long before the stored expiry. */ @@ -42,5 +42,5 @@ export const withingsTokenDescriptor: OAuthTokenDescriptor }, loadCredentials: (userId) => getUserWithingsCredentials(userId), refresh: (refreshToken, credentials) => - refreshAccessToken(refreshToken, credentials), + withingsOAuth.refresh(refreshToken, credentials), }; diff --git a/src/lib/withings/oauth.ts b/src/lib/withings/oauth.ts new file mode 100644 index 000000000..56c697708 --- /dev/null +++ b/src/lib/withings/oauth.ts @@ -0,0 +1,108 @@ +/** + * The Withings OAuth client: consent URL, code exchange and refresh, over the + * shared factory (`integrations/oauth-kit/client.ts`). + * + * Withings answers HTTP 200 with the real status inside the body + * (`{ status, body, error }`), so its answers are classified by + * `classifyWithingsResponse`, the token sits under `body`, and every token + * request names `action=requesttoken`. A failure is filed under the verbs + * `token` and `refresh`. + */ +import { + defineOAuthClient, + type OAuthClientCredentials, +} from "@/lib/integrations/oauth-kit/client"; +import { derivedRedirectUri } from "@/lib/integrations/oauth-kit/redirect-uri"; + +import { + WithingsApiError, + classifyWithingsResponse, + type WithingsBodyLike, +} from "./response-classifier"; + +export type WithingsCredentials = OAuthClientCredentials; + +/** + * The scope set HealthLog requests: + * + * - `user.metrics` : every meastype the Measure, Heart-list and Sleep + * endpoints expose (weight, BP, pulse, SpO2, body + * composition, temperature, VO2 max, ...) + * - `user.activity` : steps, active energy, distance, floors. + * + * A connection granted `user.metrics` only (or with no stored scope) is shown + * a reconnect banner on the integrations card. + */ +export const WITHINGS_OAUTH_SCOPE = "user.metrics,user.activity" as const; + +/** + * The persisted scope string as a set. A legacy NULL is the empty set, so the + * reconnect-banner condition reads true. + */ +export function parseWithingsScope(scope: string | null): Set { + if (!scope) return new Set(); + return new Set( + scope + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + ); +} + +/** Whether the grant holds `user.activity`, which Activity and Sleep need. */ +export function hasActivityScope(scope: string | null): boolean { + return parseWithingsScope(scope).has("user.activity"); +} + +export interface WithingsTokenResponse { + userid: string; + access_token: string; + refresh_token: string; + expires_in: number; +} + +function withingsError(body: unknown): string | undefined { + if (body === null || typeof body !== "object") return undefined; + const error = (body as { error?: unknown }).error; + return typeof error === "string" ? error : undefined; +} + +export const withingsOAuth = defineOAuthClient({ + service: "withings", + authorizeUrl: "https://account.withings.com/oauth2_user/authorize2", + tokenUrl: "https://wbsapi.withings.net/v2/oauth2", + redirectUri: () => + derivedRedirectUri("WITHINGS_REDIRECT_URI", "/api/withings/callback"), + authorizeParams: () => ({ scope: WITHINGS_OAUTH_SCOPE }), + clientAuth: { kind: "form" }, + extraFields: { + exchange: { action: "requesttoken" }, + refresh: { action: "requesttoken" }, + }, + refresh: true, + classify: (httpStatus, body) => { + const verdict = classifyWithingsResponse( + httpStatus, + body as WithingsBodyLike | null, + ); + return { + classification: verdict.classification, + status: verdict.withingsStatus, + reason: verdict.reason, + }; + }, + upstreamError: withingsError, + failureVerb: { exchange: "token", refresh: "refresh" }, + error: (failure) => + new WithingsApiError({ + verb: failure.verb, + classification: failure.classification, + withingsStatus: failure.status, + reason: failure.reason, + upstreamError: failure.upstreamError, + }), + tokenOf: (body) => + body !== null && typeof body === "object" + ? (body as { body?: unknown }).body + : null, +}); diff --git a/src/lib/withings/sync-activity.ts b/src/lib/withings/sync-activity.ts index b5199cb0a..1708d5fcd 100644 --- a/src/lib/withings/sync-activity.ts +++ b/src/lib/withings/sync-activity.ts @@ -46,11 +46,8 @@ import { getUnitForType } from "@/lib/validations/measurement"; import { dropImplausibleMeasurements } from "@/lib/measurements/plausibility-gate"; import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; -import { - hasActivityScope, - WITHINGS_MAX_PAGES, - withingsNextOffset, -} from "./client"; +import { WITHINGS_MAX_PAGES, withingsNextOffset } from "./client"; +import { hasActivityScope } from "./oauth"; import { WithingsApiError, classifyWithingsResponse, diff --git a/src/lib/withings/sync-sleep.ts b/src/lib/withings/sync-sleep.ts index c8b99eaf6..5ba45ec8e 100644 --- a/src/lib/withings/sync-sleep.ts +++ b/src/lib/withings/sync-sleep.ts @@ -55,7 +55,7 @@ import { reconcileExternalMeasurement, } from "@/lib/measurements/reconcile-external-measurement"; -import { hasActivityScope } from "./client"; +import { hasActivityScope } from "./oauth"; import { nextSleepStageReads, parseSleepStageReads, diff --git a/src/lib/withings/sync.ts b/src/lib/withings/sync.ts index 86d780388..c49f0838e 100644 --- a/src/lib/withings/sync.ts +++ b/src/lib/withings/sync.ts @@ -26,7 +26,6 @@ import { type WithinPlanResult, } from "./api-plan"; import { annotate, getEvent } from "@/lib/logging/context"; -import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit"; import { dropImplausibleMeasurements } from "@/lib/measurements/plausibility-gate"; import { isReauthRequired, @@ -39,15 +38,7 @@ import { WithingsApiError, type WithingsClassification, } from "./response-classifier"; -import { - collapseToTypeDayKeys, - recomputeBucketsForKeys, -} from "@/lib/rollups/measurement-rollups"; -import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; -import { - invalidateUserDashboardSnapshot, - invalidateUserMeasurements, -} from "@/lib/cache/invalidate"; +import { afterProviderWrite } from "@/lib/integrations/sync-kit/after-write"; import type { SyncWriteResult } from "@/lib/outcome/written-outcome"; import { obtainAccessToken } from "@/lib/integrations/oauth-kit/token"; import { withingsTokenDescriptor } from "./oauth-descriptor"; @@ -430,53 +421,17 @@ export async function syncUserMeasurements( } } - void emitInsertedMeasurementArrivals( + // The shared tail: arrivals, the rollup fold, the read caches back to the + // earliest touched reading and the touched status cards. The dashboard + // snapshot is evicted too: this server-side sync produces no client + // mutation event, so its cached body would otherwise outlive the import. + await afterProviderWrite({ userId, - insertedArrivals, - "withings", - ).catch(() => {}); - - // v1.4.39.1 — refresh the persistent rollup table for every distinct - // (type, day) the sync touched. The chart-data + analytics read paths - // consume these buckets via `source=rollup`; without this hook, - // Withings-only metrics stayed off the rollup tier and the dashboard - // chart at the 30-day range under-counted recent days. - try { - const keys = collapseToTypeDayKeys(touched); - await recomputeBucketsForKeys(userId, keys); - - // v1.8.0 — a Withings sync that lands new weight / BP / pulse / - // body-comp rows dirties the matching per-metric assessment caches. - // Drop them so the next mount / nightly warm pass regenerates - // against the new data. Fire-and-forget: never fails the sync. - invalidateStatusInsightsForTypes( - userId, - keys.map((k) => k.type), - { source: "sync" }, - ).catch((err) => { - getEvent()?.addWarning( - `withings: status-insight invalidate failed for ${userId}: ${err}`, - ); - }); - - // v1.18.9 (#38) — hard-evict the dashboard snapshot so a focus-refetch - // after this server-side sync returns the freshly-imported readings. - // The server-side Withings path produces no client mutation event, so - // without this the snapshot's `cachedSwr` entry kept serving the - // pre-sync body until its ~180 s TTL lapsed. - if (keys.length > 0) { - invalidateUserDashboardSnapshot(userId); - // Background-sync posture (mirrors the Fitbit tail): mark the - // per-user analytics / correlations / targets / achievements cells - // stale so the imported rows reach the cached readers before their - // TTL lapses — the correlations route documents this invariant. - invalidateUserMeasurements(userId); - } - } catch (err) { - getEvent()?.addWarning( - `withings: rollup recompute failed for ${userId}: ${err}`, - ); - } + provider: "withings", + touched, + arrivals: { rows: insertedArrivals, source: "withings" }, + evictDashboard: true, + }); // v1.28.39 — HOLD the watermark on any hard row failure. Implements the // documented status-tracking contract above ("downstream measurement-upsert diff --git a/src/lib/withings/webhook-handler.ts b/src/lib/withings/webhook-handler.ts index 0405653bf..f831c98ca 100644 --- a/src/lib/withings/webhook-handler.ts +++ b/src/lib/withings/webhook-handler.ts @@ -31,6 +31,10 @@ import { getClientIp, safeJson } from "@/lib/api-response"; import { annotate, getEvent } from "@/lib/logging/context"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { enqueueWithingsEcgSync } from "@/lib/jobs/withings-ecg-queue"; +import { + WITHINGS_ACTIVITY_QUEUE, + WITHINGS_SLEEP_QUEUE, +} from "@/lib/jobs/integration-poll-queues"; /** * v1.4.25 — appli categories that have their own sync routine @@ -41,8 +45,6 @@ import { enqueueWithingsEcgSync } from "@/lib/jobs/withings-ecg-queue"; */ const WITHINGS_ACTIVITY_APPLI = 16; const WITHINGS_SLEEP_APPLI = 44; -const WITHINGS_ACTIVITY_QUEUE = "withings-activity-sync"; -const WITHINGS_SLEEP_QUEUE = "withings-sleep-sync"; /** * Constant-time comparison helper. Returns false unless both inputs have diff --git a/tests/integration/__golden__/queue-registration.json b/tests/integration/__golden__/queue-registration.json new file mode 100644 index 000000000..47c00bd1b --- /dev/null +++ b/tests/integration/__golden__/queue-registration.json @@ -0,0 +1,3665 @@ +{ + "registeredQueueCount": 122, + "registeredQueues": [ + "achievement-unlock-sweep", + "apple-health-import", + "apple-health-import-reconcile", + "apple-health-import-v2", + "arrival-reaction-cleanup", + "audit-log-cleanup", + "auth-ledger-cleanup", + "backup-restore", + "coach-memory-refresh", + "coach-message-cleanup", + "coach-nudge", + "coach-plan-review", + "coach-reminder-sweep", + "compaction-tombstone-purge", + "cumulative-pr-rederive", + "cycle-prediction-refresh", + "cycle-reminder-check", + "daily-briefing", + "data-arrival", + "data-backup", + "data-backup-offhost", + "data-restore-drill", + "dawarich-sync", + "dense-intraday-hourly-rebuild", + "dense-intraday-retention", + "document-ai-run", + "document-ai-run-reaper", + "document-content-index-backfill", + "document-index", + "document-summary", + "document-summary-catchup", + "document-summary-reaper", + "document-thumbnail", + "document-thumbnail-backfill", + "document-tombstone-purge", + "drain-per-sample-cumulative", + "encryption-key-rotate", + "environment-aq-history", + "environment-fetch", + "feedback-aggregator", + "fitbit-backfill", + "fitbit-oauth-state-cleanup", + "fitbit-sleep-repair", + "fitbit-sync", + "free-text-encryption-backfill", + "geo-backfill", + "geolite2-fetch", + "google-health-backfill", + "google-health-oauth-state-cleanup", + "google-health-sleep-repair", + "google-health-sync", + "health-connect-import", + "host-metric-sample", + "idempotency-cleanup", + "insight-pregenerate", + "insight-status-generate", + "insights-general-status", + "intake-auto-skip", + "intake-auto-take", + "intake-slot-dedup", + "integration-backfill-admission", + "integration-watchdog-daily", + "integration-watchdog-hourly", + "lab-biomarker-backfill", + "mcp-token-cleanup", + "mean-consolidation", + "measurement-fold-repair", + "measurement-maintenance", + "measurement-reminder-check", + "measurement-tombstone-cleanup", + "med-notes-encryption-backfill", + "medication-intake-import", + "medication-inventory-expire", + "medication-low-stock", + "medication-reminder-check", + "mood-prognosis-refresh", + "mood-reminder-check", + "mood-reminder-cleanup", + "mood-rollup-full-backfill", + "mood-rollup-recompute", + "morning-digest-refresh", + "nightscout-sync", + "note-encryption-backfill", + "offhost-backup-purge", + "oidc-native-handoff-cleanup", + "oura-backfill", + "oura-sync", + "period-narrative-warm", + "polar-sync", + "pr-detection", + "pulse-day-mean", + "push-attempt-cleanup", + "rate-limit-cleanup", + "reaction-line-generate", + "recovery-score-compute", + "reminder-satisfy", + "rollup-full-backfill", + "rollup-recompute", + "sleep-timeline-backfill", + "soft-delete-purge", + "step-consolidation", + "step-consolidation-repair", + "step-up-elevation-cleanup", + "strain-score-compute", + "strava-backfill", + "strava-sync", + "stress-score-compute", + "supplement-nutrient-recompute", + "tls-pin-monitor", + "whoop-backfill", + "whoop-cycle-sync", + "whoop-oauth-state-cleanup", + "whoop-recovery-sync", + "whoop-sleep-sync", + "whoop-workout-sync", + "withings-activity-sync", + "withings-ecg-sync", + "withings-fallback-sync", + "withings-oauth-state-cleanup", + "withings-sleep-sync", + "workout-insight-claim-cleanup", + "workout-insight-generate" + ], + "queues": [ + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "achievement-unlock-sweep", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "apple-health-import", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "apple-health-import-reconcile", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "apple-health-import-v2", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "arrival-reaction-cleanup", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "audit-log-cleanup", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "auth-ledger-cleanup", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "backup-restore", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "coach-memory-refresh", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "coach-message-cleanup", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "coach-nudge", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "coach-plan-review", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "coach-reminder-sweep", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "compaction-tombstone-purge", + "notify": false, + "partition": false, + "policy": "short", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "cumulative-pr-rederive", + "notify": false, + "partition": false, + "policy": "exclusive", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "cycle-prediction-refresh", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "cycle-reminder-check", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "daily-briefing", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "data-arrival", + "notify": false, + "partition": false, + "policy": "exclusive", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "data-backup", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "data-backup-offhost", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "data-restore-drill", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "dawarich-sync", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "dense-intraday-hourly-rebuild", + "notify": false, + "partition": false, + "policy": "exclusive", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "dense-intraday-retention", + "notify": false, + "partition": false, + "policy": "exclusive", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "document-ai-run", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "document-ai-run-reaper", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "document-content-index-backfill", + "notify": false, + "partition": false, + "policy": "exclusive", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "document-index", + "notify": false, + "partition": false, + "policy": "short", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "document-summary", + "notify": false, + "partition": false, + "policy": "short", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "document-summary-catchup", + "notify": false, + "partition": false, + "policy": "short", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "document-summary-reaper", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "document-thumbnail", + "notify": false, + "partition": false, + "policy": "short", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "document-thumbnail-backfill", + "notify": false, + "partition": false, + "policy": "exclusive", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "document-tombstone-purge", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "drain-per-sample-cumulative", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "encryption-key-rotate", + "notify": false, + "partition": false, + "policy": "exclusive", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "environment-aq-history", + "notify": false, + "partition": false, + "policy": "short", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "environment-fetch", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "feedback-aggregator", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "fitbit-backfill", + "notify": false, + "partition": false, + "policy": "exclusive", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "fitbit-oauth-state-cleanup", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "fitbit-sleep-repair", + "notify": false, + "partition": false, + "policy": "exclusive", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "fitbit-sync", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "free-text-encryption-backfill", + "notify": false, + "partition": false, + "policy": "exclusive", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "geo-backfill", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "geolite2-fetch", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "google-health-backfill", + "notify": false, + "partition": false, + "policy": "exclusive", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "google-health-oauth-state-cleanup", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "google-health-sleep-repair", + "notify": false, + "partition": false, + "policy": "exclusive", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "google-health-sync", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "health-connect-import", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 345600, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "host-metric-sample", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "idempotency-cleanup", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "insight-pregenerate", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 345600, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "insight-status-generate", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "insights-general-status", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "intake-auto-skip", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "intake-auto-take", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "intake-slot-dedup", + "notify": false, + "partition": false, + "policy": "exclusive", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "integration-backfill-admission", + "notify": false, + "partition": false, + "policy": "exclusive", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "integration-watchdog-daily", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "integration-watchdog-hourly", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "lab-biomarker-backfill", + "notify": false, + "partition": false, + "policy": "exclusive", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "mcp-token-cleanup", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "mean-consolidation", + "notify": false, + "partition": false, + "policy": "exclusive", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "measurement-fold-repair", + "notify": false, + "partition": false, + "policy": "short", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "measurement-maintenance", + "notify": false, + "partition": false, + "policy": "exclusive", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "measurement-reminder-check", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "measurement-tombstone-cleanup", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "med-notes-encryption-backfill", + "notify": false, + "partition": false, + "policy": "exclusive", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "medication-intake-import", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "medication-inventory-expire", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "medication-low-stock", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "medication-reminder-check", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "mood-prognosis-refresh", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "mood-reminder-check", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "mood-reminder-cleanup", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "mood-rollup-full-backfill", + "notify": false, + "partition": false, + "policy": "exclusive", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "mood-rollup-recompute", + "notify": false, + "partition": false, + "policy": "short", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "morning-digest-refresh", + "notify": false, + "partition": false, + "policy": "exclusive", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "nightscout-sync", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "note-encryption-backfill", + "notify": false, + "partition": false, + "policy": "exclusive", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "offhost-backup-purge", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "oidc-native-handoff-cleanup", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "oura-backfill", + "notify": false, + "partition": false, + "policy": "exclusive", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "oura-sync", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "period-narrative-warm", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "polar-sync", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "pr-detection", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "pulse-day-mean", + "notify": false, + "partition": false, + "policy": "exclusive", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "push-attempt-cleanup", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "rate-limit-cleanup", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "reaction-line-generate", + "notify": false, + "partition": false, + "policy": "exclusive", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "recovery-score-compute", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "reminder-satisfy", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "rollup-full-backfill", + "notify": false, + "partition": false, + "policy": "exclusive", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 345600, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "rollup-recompute", + "notify": false, + "partition": false, + "policy": "short", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "sleep-timeline-backfill", + "notify": false, + "partition": false, + "policy": "exclusive", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "soft-delete-purge", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "step-consolidation", + "notify": false, + "partition": false, + "policy": "exclusive", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "step-consolidation-repair", + "notify": false, + "partition": false, + "policy": "exclusive", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "step-up-elevation-cleanup", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "strain-score-compute", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "strava-backfill", + "notify": false, + "partition": false, + "policy": "exclusive", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "strava-sync", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "stress-score-compute", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "supplement-nutrient-recompute", + "notify": false, + "partition": false, + "policy": "short", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "tls-pin-monitor", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "whoop-backfill", + "notify": false, + "partition": false, + "policy": "exclusive", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "whoop-cycle-sync", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "whoop-oauth-state-cleanup", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "whoop-recovery-sync", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "whoop-sleep-sync", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "whoop-workout-sync", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "withings-activity-sync", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "withings-ecg-sync", + "notify": false, + "partition": false, + "policy": "short", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "withings-fallback-sync", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "withings-oauth-state-cleanup", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "withings-sleep-sync", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "workout-insight-claim-cleanup", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + }, + { + "dead_letter": null, + "deletion_seconds": 604800, + "expire_seconds": 900, + "heartbeat_seconds": null, + "name": "workout-insight-generate", + "notify": false, + "partition": false, + "policy": "standard", + "retention_seconds": 1209600, + "retry_backoff": false, + "retry_delay": 0, + "retry_delay_max": null, + "retry_limit": 2 + } + ], + "schedules": [ + { + "cron": "25 4 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "achievement-unlock-sweep", + "options": { + "retryLimit": 0, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "*/15 * * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "apple-health-import-reconcile", + "options": { + "retryLimit": 0, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "10 4 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "arrival-reaction-cleanup", + "options": { + "retryLimit": 0, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "15 3 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "audit-log-cleanup", + "options": { + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "35 3 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "auth-ledger-cleanup", + "options": { + "retryLimit": 0, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "50 3 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "coach-message-cleanup", + "options": { + "retryLimit": 0, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "15 5 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "coach-nudge", + "options": { + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "25 5 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "coach-plan-review", + "options": { + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "20 5 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "coach-reminder-sweep", + "options": { + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "20 4 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "cycle-prediction-refresh", + "options": { + "retryLimit": 0, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "*/15 * * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "cycle-reminder-check", + "options": { + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "*/15 * * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "daily-briefing", + "options": { + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "0 3 * * 0", + "data": {}, + "key": "", + "kind": "cron", + "name": "data-backup", + "options": { + "expireInSeconds": 7200, + "heartbeatSeconds": 300, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "30 2 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "data-backup-offhost", + "options": { + "expireInSeconds": 14400, + "heartbeatSeconds": 300, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "11 4 1 * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "data-restore-drill", + "options": { + "expireInSeconds": 14400, + "retryLimit": 0, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "20 3 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "dawarich-sync", + "options": { + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "*/5 * * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "document-ai-run-reaper", + "options": { + "retryLimit": 0, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "50 * * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "document-summary-reaper", + "options": { + "retryLimit": 0, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "10 4 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "document-tombstone-purge", + "options": { + "retryLimit": 0, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "45 3 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "drain-per-sample-cumulative", + "options": { + "retryLimit": 0, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "40 2 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "environment-aq-history", + "options": { + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "10 2 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "environment-fetch", + "options": { + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "0 4 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "feedback-aggregator", + "options": { + "retryLimit": 0, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "24 3 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "fitbit-oauth-state-cleanup", + "options": { + "retryLimit": 0, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "8 * * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "fitbit-sync", + "options": { + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "7 4 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "free-text-encryption-backfill", + "options": { + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "40 * * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "geo-backfill", + "options": { + "retryLimit": 0, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "0 4 8 * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "geolite2-fetch", + "options": { + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "26 3 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "google-health-oauth-state-cleanup", + "options": { + "retryLimit": 0, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "18 * * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "google-health-sync", + "options": { + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "*/5 * * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "host-metric-sample", + "options": { + "retryLimit": 0, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "0 3 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "idempotency-cleanup", + "options": { + "retryLimit": 0, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "30 4 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "insight-pregenerate", + "options": { + "expireInSeconds": 10800, + "retryBackoff": true, + "retryDelay": 60, + "retryLimit": 3, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "0 2 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "insights-general-status", + "options": { + "expireInSeconds": 7200, + "retryBackoff": true, + "retryDelay": 60, + "retryLimit": 3, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "5 * * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "intake-auto-skip", + "options": { + "retryLimit": 0, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "10,25,40,55 * * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "intake-auto-take", + "options": { + "retryLimit": 0, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "28 3 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "intake-slot-dedup", + "options": { + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "52 4 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "integration-watchdog-daily", + "options": { + "retryLimit": 0, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "47 * * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "integration-watchdog-hourly", + "options": { + "retryLimit": 0, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "0 4 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "mcp-token-cleanup", + "options": { + "retryLimit": 0, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "40 3 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "measurement-maintenance", + "options": { + "expireInSeconds": 21600, + "retryLimit": 0, + "singletonKey": "measurement-maintenance", + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "*/15 * * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "measurement-reminder-check", + "options": { + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "40 3 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "measurement-tombstone-cleanup", + "options": { + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "5 4 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "med-notes-encryption-backfill", + "options": { + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "30 3 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "medication-inventory-expire", + "options": { + "retryLimit": 0, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "0 9 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "medication-low-stock", + "options": { + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "*/15 * * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "medication-reminder-check", + "options": { + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "35 4 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "mood-prognosis-refresh", + "options": { + "retryLimit": 0, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "*/15 * * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "mood-reminder-check", + "options": { + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "25 3 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "mood-reminder-cleanup", + "options": { + "retryLimit": 0, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "11 * * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "nightscout-sync", + "options": { + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "55 3 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "note-encryption-backfill", + "options": { + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "40 3 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "offhost-backup-purge", + "options": { + "retryLimit": 0, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "28 3 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "oidc-native-handoff-cleanup", + "options": { + "retryLimit": 0, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "15 * * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "oura-sync", + "options": { + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "5 5 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "period-narrative-warm", + "options": { + "expireInSeconds": 10800, + "retryBackoff": true, + "retryDelay": 60, + "retryLimit": 3, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "13 * * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "polar-sync", + "options": { + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "*/30 * * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "pr-detection", + "options": { + "expireInSeconds": 1500, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "35 3 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "push-attempt-cleanup", + "options": { + "retryLimit": 0, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "*/5 * * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "rate-limit-cleanup", + "options": { + "retryLimit": 0, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "45 4 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "recovery-score-compute", + "options": { + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "42 3 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "soft-delete-purge", + "options": { + "retryLimit": 0, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "*/15 * * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "step-up-elevation-cleanup", + "options": { + "retryLimit": 0, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "55 4 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "strain-score-compute", + "options": { + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "17 * * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "strava-sync", + "options": { + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "50 4 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "stress-score-compute", + "options": { + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "7 */6 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "tls-pin-monitor", + "options": { + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "50 * * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "whoop-cycle-sync", + "options": { + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "22 3 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "whoop-oauth-state-cleanup", + "options": { + "retryLimit": 0, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "5 * * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "whoop-recovery-sync", + "options": { + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "20 * * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "whoop-sleep-sync", + "options": { + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "35 * * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "whoop-workout-sync", + "options": { + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "0 * * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "withings-activity-sync", + "options": { + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "41 * * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "withings-ecg-sync", + "options": { + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "0 * * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "withings-fallback-sync", + "options": { + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "20 3 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "withings-oauth-state-cleanup", + "options": { + "retryLimit": 0, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "15 * * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "withings-sleep-sync", + "options": { + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + }, + { + "cron": "52 3 * * *", + "data": {}, + "key": "", + "kind": "cron", + "name": "workout-insight-claim-cleanup", + "options": { + "retryLimit": 0, + "tz": "Europe/Berlin" + }, + "timezone": "Europe/Berlin" + } + ], + "bindings": [ + { + "queue": "achievement-unlock-sweep", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "apple-health-import", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "apple-health-import-reconcile", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "apple-health-import-v2", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "arrival-reaction-cleanup", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "audit-log-cleanup", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "auth-ledger-cleanup", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "backup-restore", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "coach-memory-refresh", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "coach-message-cleanup", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "coach-nudge", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "coach-plan-review", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "coach-reminder-sweep", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "compaction-tombstone-purge", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "cumulative-pr-rederive", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "cycle-prediction-refresh", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "cycle-reminder-check", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "daily-briefing", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "data-arrival", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "data-backup", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "data-backup-offhost", + "options": { + "includeMetadata": true, + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "data-restore-drill", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "dawarich-sync", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "dense-intraday-hourly-rebuild", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "dense-intraday-retention", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "document-ai-run", + "options": { + "localConcurrency": 2 + } + }, + { + "queue": "document-ai-run-reaper", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "document-content-index-backfill", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "document-index", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "document-summary", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "document-summary-catchup", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "document-summary-reaper", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "document-thumbnail", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "document-thumbnail-backfill", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "document-tombstone-purge", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "drain-per-sample-cumulative", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "encryption-key-rotate", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "environment-aq-history", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "environment-fetch", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "feedback-aggregator", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "fitbit-backfill", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "fitbit-oauth-state-cleanup", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "fitbit-sleep-repair", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "fitbit-sync", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "free-text-encryption-backfill", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "geo-backfill", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "geolite2-fetch", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "google-health-backfill", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "google-health-oauth-state-cleanup", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "google-health-sleep-repair", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "google-health-sync", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "health-connect-import", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "host-metric-sample", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "idempotency-cleanup", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "insight-pregenerate", + "options": { + "localConcurrency": 2 + } + }, + { + "queue": "insight-status-generate", + "options": { + "localConcurrency": 2 + } + }, + { + "queue": "insights-general-status", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "intake-auto-skip", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "intake-auto-take", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "intake-slot-dedup", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "integration-backfill-admission", + "options": { + "groupConcurrency": 1, + "localConcurrency": 1 + } + }, + { + "queue": "integration-watchdog-daily", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "integration-watchdog-hourly", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "lab-biomarker-backfill", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "mcp-token-cleanup", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "mean-consolidation", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "measurement-fold-repair", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "measurement-maintenance", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "measurement-reminder-check", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "measurement-tombstone-cleanup", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "med-notes-encryption-backfill", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "medication-intake-import", + "options": { + "includeMetadata": true, + "localConcurrency": 2 + } + }, + { + "queue": "medication-inventory-expire", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "medication-low-stock", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "medication-reminder-check", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "mood-prognosis-refresh", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "mood-reminder-check", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "mood-reminder-cleanup", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "mood-rollup-full-backfill", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "mood-rollup-recompute", + "options": { + "localConcurrency": 2 + } + }, + { + "queue": "morning-digest-refresh", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "nightscout-sync", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "note-encryption-backfill", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "offhost-backup-purge", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "oidc-native-handoff-cleanup", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "oura-backfill", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "oura-sync", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "period-narrative-warm", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "polar-sync", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "pr-detection", + "options": { + "localConcurrency": 5 + } + }, + { + "queue": "pulse-day-mean", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "push-attempt-cleanup", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "rate-limit-cleanup", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "reaction-line-generate", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "recovery-score-compute", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "reminder-satisfy", + "options": { + "localConcurrency": 5 + } + }, + { + "queue": "rollup-full-backfill", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "rollup-recompute", + "options": { + "localConcurrency": 2 + } + }, + { + "queue": "sleep-timeline-backfill", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "soft-delete-purge", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "step-consolidation", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "step-consolidation-repair", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "step-up-elevation-cleanup", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "strain-score-compute", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "strava-backfill", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "strava-sync", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "stress-score-compute", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "supplement-nutrient-recompute", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "tls-pin-monitor", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "whoop-backfill", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "whoop-cycle-sync", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "whoop-oauth-state-cleanup", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "whoop-recovery-sync", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "whoop-sleep-sync", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "whoop-workout-sync", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "withings-activity-sync", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "withings-ecg-sync", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "withings-fallback-sync", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "withings-oauth-state-cleanup", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "withings-sleep-sync", + "options": { + "localConcurrency": 1 + } + }, + { + "queue": "workout-insight-claim-cleanup", + "options": { + "localConcurrency": 1, + "pollingIntervalSeconds": 30 + } + }, + { + "queue": "workout-insight-generate", + "options": { + "localConcurrency": 1 + } + } + ] +} diff --git a/tests/integration/backup-restore-job.test.ts b/tests/integration/backup-restore-job.test.ts index 670143467..1902b918a 100644 --- a/tests/integration/backup-restore-job.test.ts +++ b/tests/integration/backup-restore-job.test.ts @@ -44,12 +44,12 @@ import { setGlobalBoss } from "@/lib/jobs/boss-instance"; import { runJob } from "@/lib/jobs/run-job"; import { BACKUP_RESTORE_MAX_ATTEMPTS, - BACKUP_RESTORE_QUEUE, handleBackupRestore, readBackupRestoreJob, runBackupRestoreJob, sweepInterruptedRestores, } from "@/lib/jobs/backup-restore"; +import { BACKUP_RESTORE_QUEUE } from "@/lib/jobs/backup-restore-queue"; import { backupPayloadSchema } from "@/lib/validations/backup"; import { cookieJar, headerJar } from "./mock-next-headers"; diff --git a/tests/integration/google-health-backfill-multi-year.test.ts b/tests/integration/google-health-backfill-multi-year.test.ts index 40869dace..b45003c83 100644 --- a/tests/integration/google-health-backfill-multi-year.test.ts +++ b/tests/integration/google-health-backfill-multi-year.test.ts @@ -362,7 +362,7 @@ describe(`Google Health backfill over ${DAYS} days of heart rate (#1220)`, () => const { handleGoogleHealthSync } = await import("@/lib/jobs/reminder/google-health-sync"); const { GOOGLE_HEALTH_TRANSIENT_RETRY } = - await import("@/lib/google-health/client"); + await import("@/lib/google-health/request"); GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs = 0; // A server error and a lost request in the first slice, and a rate limit @@ -525,7 +525,7 @@ describe(`Google Health backfill over ${DAYS} days of heart rate (#1220)`, () => const { handleGoogleHealthSync } = await import("@/lib/jobs/reminder/google-health-sync"); const { GOOGLE_HEALTH_TRANSIENT_RETRY } = - await import("@/lib/google-health/client"); + await import("@/lib/google-health/request"); GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs = 0; const prisma = getPrismaClient(); diff --git a/tests/integration/google-health-backfill-rate-limit.test.ts b/tests/integration/google-health-backfill-rate-limit.test.ts index 3ddb049fa..457a00d0c 100644 --- a/tests/integration/google-health-backfill-rate-limit.test.ts +++ b/tests/integration/google-health-backfill-rate-limit.test.ts @@ -464,7 +464,7 @@ describe("Google Health backfill under rate limits and token expiry (#1194)", () // A slice whose every heart-rate request fails moves nothing: it is // stuck, says where, and queues nothing. const { GOOGLE_HEALTH_TRANSIENT_RETRY } = - await import("@/lib/google-health/client"); + await import("@/lib/google-health/request"); const baseDelay = GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs; GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs = 0; fake.failHeartRate = true; diff --git a/tests/integration/google-health-dense-backfill-memory.test.ts b/tests/integration/google-health-dense-backfill-memory.test.ts index 31414108e..51af6c4b4 100644 --- a/tests/integration/google-health-dense-backfill-memory.test.ts +++ b/tests/integration/google-health-dense-backfill-memory.test.ts @@ -228,8 +228,9 @@ describe("Google Health backfill over a dense heart-rate stream (#1023)", () => }, 900_000); it("the same pages collected into one array outgrow the budget", async () => { - const { fetchDataPoints, GOOGLE_HEALTH_DATA_TYPES } = - await import("@/lib/google-health/client"); + const { fetchDataPoints } = await import("@/lib/google-health/data-points"); + const { GOOGLE_HEALTH_DATA_TYPES } = + await import("@/lib/google-health/mappers"); let held: unknown[] = []; const { growth, samples } = await growthPastFirstQuarter(async () => { held = await fetchDataPoints( diff --git a/tests/integration/google-health-workout-terminal.test.ts b/tests/integration/google-health-workout-terminal.test.ts index f3cc5189e..d3cb65c78 100644 --- a/tests/integration/google-health-workout-terminal.test.ts +++ b/tests/integration/google-health-workout-terminal.test.ts @@ -14,12 +14,15 @@ const mocks = vi.hoisted(() => ({ noteHardFailure: vi.fn(), })); -vi.mock("@/lib/google-health/client", () => ({ +vi.mock("@/lib/google-health/data-points", () => ({ GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE: 25, + fetchDataPoints: mocks.fetchDataPoints, +})); + +vi.mock("@/lib/google-health/mappers", () => ({ GOOGLE_HEALTH_DATA_TYPES: { exercise: { key: "exercise", dataType: "exercise" }, }, - fetchDataPoints: mocks.fetchDataPoints, mapWorkout: mocks.mapWorkout, })); diff --git a/tests/integration/health-connect-import.test.ts b/tests/integration/health-connect-import.test.ts index e7dceb5bd..3c965acac 100644 --- a/tests/integration/health-connect-import.test.ts +++ b/tests/integration/health-connect-import.test.ts @@ -34,9 +34,9 @@ import { importHealthConnectExport } from "@/lib/import/health-connect/import"; import { runDenseIntradayRetention } from "@/lib/measurements/dense-intraday-retention"; import { HEALTH_CONNECT_IMPORT_KIND, - HEALTH_CONNECT_IMPORT_QUEUE, runHealthConnectImport, } from "@/lib/jobs/health-connect-import-worker"; +import { HEALTH_CONNECT_IMPORT_QUEUE } from "@/lib/jobs/health-connect-import-queue"; import { _setWorkerPrismaForTests, reconcileOrphanImportJobs, diff --git a/tests/integration/oura-refresh-serialization.test.ts b/tests/integration/oura-refresh-serialization.test.ts index d42f145fe..aa65c2393 100644 --- a/tests/integration/oura-refresh-serialization.test.ts +++ b/tests/integration/oura-refresh-serialization.test.ts @@ -26,7 +26,6 @@ vi.mock("@/lib/oura/client", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - refreshAccessToken: ouraRefresh, fetchReadiness: collection, fetchSleep: collection, fetchDailyActivity: collection, @@ -38,6 +37,13 @@ vi.mock("@/lib/oura/client", async (importOriginal) => { fetchDailyCyclePhases: collection, }; }); +vi.mock("@/lib/oura/oauth", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ouraOAuth: { ...actual.ouraOAuth, refresh: ouraRefresh }, + }; +}); import { decrypt, encrypt } from "@/lib/crypto"; import { OuraApiError } from "@/lib/oura/response-classifier"; diff --git a/tests/integration/provider-after-write.test.ts b/tests/integration/provider-after-write.test.ts new file mode 100644 index 000000000..8ee2d3c54 --- /dev/null +++ b/tests/integration/provider-after-write.test.ts @@ -0,0 +1,99 @@ +/** + * The provider write step against the real schema: what a sync leaves behind + * after `afterProviderWrite` and `afterProviderBackfill`. + * + * - the touched days are folded into the DAY rollup the read paths use; + * - a write that touched nothing folds nothing and reports no failure; + * - a backfill refolds the whole touched range once, including a day that + * was written without the per-write fold. + * + * The data-arrival send and the status re-warm are queue work (the boss is + * absent here, so the arrival send is a no-op); they are pinned by the unit + * tests of the providers. What matters here is what Postgres holds. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { + afterProviderBackfill, + afterProviderWrite, +} from "@/lib/integrations/sync-kit/after-write"; + +import { makeMeasurement, makeUser } from "../kit/factories"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +beforeEach(() => truncateAllTables(getPrismaClient())); + +async function dayRollups(userId: string) { + return getPrismaClient().measurementRollup.findMany({ + where: { userId, granularity: "DAY", type: "WEIGHT" }, + select: { bucketStart: true, count: true, mean: true, source: true }, + orderBy: { bucketStart: "asc" }, + }); +} + +describe("provider write step", () => { + it("folds the days a sync touched", async () => { + const user = await makeUser(); + const first = await makeMeasurement(user, { + source: "WITHINGS", + value: 80, + measuredAt: new Date("2026-03-02T07:30:00Z"), + }); + const second = await makeMeasurement(user, { + source: "WITHINGS", + value: 82, + measuredAt: new Date("2026-03-02T19:30:00Z"), + }); + + const result = await afterProviderWrite({ + userId: user.id, + provider: "withings", + touched: [first, second], + evictDashboard: true, + }); + + expect(result).toEqual({ rollupFailed: false }); + const rows = await dayRollups(user.id); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ count: 2, mean: 81, source: "WITHINGS" }); + }); + + it("does nothing for a write that touched nothing", async () => { + const user = await makeUser(); + await expect( + afterProviderWrite({ userId: user.id, provider: "polar", touched: [] }), + ).resolves.toEqual({ rollupFailed: false }); + expect(await dayRollups(user.id)).toEqual([]); + }); + + it("refolds a backfill's whole range once at the end", async () => { + const user = await makeUser(); + const days = ["2026-03-01", "2026-03-04", "2026-03-09"]; + const written = []; + for (const day of days) { + written.push( + await makeMeasurement(user, { + source: "GOOGLE_HEALTH", + value: 70, + measuredAt: new Date(`${day}T08:00:00Z`), + }), + ); + } + + await afterProviderBackfill({ + userId: user.id, + provider: "google-health", + keys: written.map((row) => ({ + type: row.type, + measuredAt: new Date( + `${row.measuredAt.toISOString().slice(0, 10)}T00:00:00Z`, + ), + })), + }); + + const rows = await dayRollups(user.id); + expect( + rows.map((row) => row.bucketStart.toISOString().slice(0, 10)), + ).toEqual(days); + }); +}); diff --git a/tests/integration/provider-token-refresh-serialization.test.ts b/tests/integration/provider-token-refresh-serialization.test.ts index db22d5cbd..120a34f63 100644 --- a/tests/integration/provider-token-refresh-serialization.test.ts +++ b/tests/integration/provider-token-refresh-serialization.test.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type * as WhoopClientModule from "@/lib/whoop/client"; -import type * as WithingsClientModule from "@/lib/withings/client"; import { getPrismaClient, truncateAllTables } from "./setup"; @@ -17,14 +16,24 @@ const { whoopRefresh, withingsRefresh, whoopRecoveries } = vi.hoisted(() => ({ vi.mock("@/lib/whoop/client", async (importOriginal) => ({ ...(await importOriginal()), - refreshAccessToken: whoopRefresh, fetchRecoveries: whoopRecoveries, })); -vi.mock("@/lib/withings/client", async (importOriginal) => ({ - ...(await importOriginal()), - refreshAccessToken: withingsRefresh, -})); +vi.mock("@/lib/whoop/oauth", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + whoopOAuth: { ...actual.whoopOAuth, refresh: whoopRefresh }, + }; +}); + +vi.mock("@/lib/withings/oauth", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + withingsOAuth: { ...actual.withingsOAuth, refresh: withingsRefresh }, + }; +}); import { encrypt, decrypt } from "@/lib/crypto"; import { getValidToken as getValidWhoopToken } from "@/lib/whoop/sync-core"; diff --git a/tests/integration/queue-registration-parity.test.ts b/tests/integration/queue-registration-parity.test.ts new file mode 100644 index 000000000..544432dc0 --- /dev/null +++ b/tests/integration/queue-registration-parity.test.ts @@ -0,0 +1,146 @@ +/** + * What the worker leaves in pg-boss when it registers its queues, pinned + * byte for byte. + * + * The worker registers every queue at boot: it creates the queue (with its + * de-duplication policy and retention), schedules its cron with the send + * options the schedule carries, and binds a handler with its concurrency and + * poll. None of that is visible to a unit test, and a change to any of it is + * invisible in production until a job is dropped, doubled or never run. This + * file runs the real registration against a real pg-boss and compares three + * things with `__golden__/queue-registration.json`: + * + * - every row of `pgboss.queue`, with the columns registration decides + * (policy, retries, expiry, retention, deletion); + * - every row of `pgboss.schedule` (cron, time zone, payload, send options); + * - every handler binding, with the work options it was bound with. + * + * The golden was written from the registrars as they stood before the + * registration became a declarative registry, so this test is the proof that + * the registry registers exactly what they did. A deliberate change to a + * queue's registration rewrites the golden in the same commit + * (`pnpm vitest run --config vitest.integration.config.mts -u `), + * and the diff shows the change. + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { PgBoss } from "pg-boss"; + +import { getPrismaClient } from "./setup"; + +interface Binding { + queue: string; + options: Record; +} + +let boss: PgBoss; +const bindings: Binding[] = []; +const returned: string[] = []; + +/** Sort an object's keys so the golden does not depend on spread order. */ +function sorted(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sorted); + if (value === null || typeof value !== "object") return value; + return Object.fromEntries( + Object.keys(value as Record) + .sort() + .map((key) => [key, sorted((value as Record)[key])]), + ); +} + +/** + * The boss the registration sees: the real one, except that a handler + * binding is recorded rather than started, so no worker polls while the + * tables are read. + */ +function recordingBoss(real: PgBoss): PgBoss { + return new Proxy(real, { + get(target, property, receiver) { + if (property === "work") { + return async (queue: string, options: Record) => { + bindings.push({ + queue, + options: sorted(options) as Record, + }); + return `work-${queue}`; + }; + } + const value = Reflect.get(target, property, receiver); + return typeof value === "function" ? value.bind(target) : value; + }, + }); +} + +async function registerEveryQueue(target: PgBoss): Promise { + const { registerAllJobGroups } = await import("@/lib/jobs/registry/index"); + returned.push(...(await registerAllJobGroups(target))); +} + +beforeAll(async () => { + boss = new PgBoss({ + connectionString: process.env.DATABASE_URL, + schedule: false, + supervise: false, + }); + await boss.start(); + await registerEveryQueue(recordingBoss(boss)); +}, 180_000); + +afterAll(async () => { + await boss?.stop({ graceful: false }); +}); + +describe("queue registration", () => { + it("leaves pg-boss exactly as the golden records it", async () => { + const prisma = getPrismaClient(); + const queues = await prisma.$queryRaw>>` + SELECT name, policy, retry_limit, retry_delay, retry_backoff, + retry_delay_max, expire_seconds, retention_seconds, + deletion_seconds, dead_letter, partition, heartbeat_seconds, + notify + FROM pgboss.queue + -- pg-boss's own queues are its business, not the registration's. + WHERE NOT starts_with(name, '__pgboss__') + ORDER BY name + `; + const schedules = await prisma.$queryRaw>>` + SELECT name, key, kind, cron, timezone, data, options + FROM pgboss.schedule + ORDER BY name, key + `; + + const snapshot = { + registeredQueueCount: returned.length, + registeredQueues: [...returned].sort(), + queues: queues.map(sorted), + schedules: schedules.map(sorted), + bindings: [...bindings].sort((a, b) => a.queue.localeCompare(b.queue)), + }; + + await expect(JSON.stringify(snapshot, null, 2) + "\n").toMatchFileSnapshot( + "./__golden__/queue-registration.json", + ); + }); + + it("binds a handler to every queue it creates, once", () => { + const bound = bindings.map((b) => b.queue); + expect(new Set(bound).size).toBe(bound.length); + expect([...bound].sort()).toEqual([...returned].sort()); + // A floor, so an empty registration cannot pass by matching an empty + // golden. + expect(returned.length).toBeGreaterThan(100); + }); + + it("loads a function for every handler it declares", async () => { + // A handler module loads on its queue's first job; a broken import would + // otherwise surface only when that job arrives in production. + const { allJobDefinitions } = await import("@/lib/jobs/registry/index"); + const definitions = allJobDefinitions(); + expect(definitions.length).toBe(returned.length); + const unloaded: string[] = []; + for (const definition of definitions) { + const handler = await definition.load({ boss }); + if (typeof handler !== "function") unloaded.push(definition.queue); + } + expect(unloaded).toEqual([]); + }); +}); diff --git a/tests/integration/routes/auth-me-documents-auto-ai-read.test.ts b/tests/integration/routes/auth-me-documents-auto-ai-read.test.ts index 6071eb8e2..fec91f067 100644 --- a/tests/integration/routes/auth-me-documents-auto-ai-read.test.ts +++ b/tests/integration/routes/auth-me-documents-auto-ai-read.test.ts @@ -27,10 +27,8 @@ import type { Prisma, User } from "@/generated/prisma/client"; import { GET, PATCH } from "@/app/api/auth/me/documents-auto-ai-read/route"; import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; import { setGlobalBoss } from "@/lib/jobs/boss-instance"; -import { - DOCUMENT_SUMMARY_CATCHUP_QUEUE, - enqueueSummaryCatchUp, -} from "@/lib/jobs/document-summary-catchup"; +import { enqueueSummaryCatchUp } from "@/lib/jobs/document-summary-catchup"; +import { DOCUMENT_SUMMARY_CATCHUP_QUEUE } from "@/lib/jobs/document-summary-catchup-queue"; import { anonymous, callRoute } from "../../kit/call-route"; import { makeSession, makeUser, uniqueSuffix } from "../../kit/factories"; diff --git a/tests/integration/strava-sync.test.ts b/tests/integration/strava-sync.test.ts index 18d7494ad..2e4c4df30 100644 --- a/tests/integration/strava-sync.test.ts +++ b/tests/integration/strava-sync.test.ts @@ -30,7 +30,7 @@ */ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { StravaDetailedActivity } from "@/lib/strava/client"; +import type { StravaDetailedActivity } from "@/lib/strava/wire-types"; import type { IntegrationStatus, User } from "@/generated/prisma/client"; import { decrypt, encrypt } from "@/lib/crypto"; import { recordSyncFailure } from "@/lib/integrations/status";