From 9beaa8f640aaea1c0bb2d875f45db08b065fb547 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 10:37:41 +0200 Subject: [PATCH 01/10] build(deps): bump the AWS SDK S3 packages to 3.1153.0 and react-query to 5.104.5 --- package.json | 6 ++-- pnpm-lock.yaml | 95 +++++++++++++++++++++++--------------------------- 2 files changed, 46 insertions(+), 55 deletions(-) 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 From 5c6a1cbb36c0eb9036c33dfeb08e582b1a0b48c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 10:44:53 +0200 Subject: [PATCH 02/10] test(jobs): pin every queue, schedule and handler binding the worker registers --- .../__golden__/queue-registration.json | 3665 +++++++++++++++++ .../queue-registration-parity.test.ts | 145 + 2 files changed, 3810 insertions(+) create mode 100644 tests/integration/__golden__/queue-registration.json create mode 100644 tests/integration/queue-registration-parity.test.ts 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/queue-registration-parity.test.ts b/tests/integration/queue-registration-parity.test.ts new file mode 100644 index 000000000..40ff5d72b --- /dev/null +++ b/tests/integration/queue-registration-parity.test.ts @@ -0,0 +1,145 @@ +/** + * 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 [integration, status, rollup, reminders, maintenance] = + await Promise.all([ + import("@/lib/jobs/reminder/register-integration-sync"), + import("@/lib/jobs/reminder/register-status"), + import("@/lib/jobs/reminder/register-rollup"), + import("@/lib/jobs/reminder/register-reminders"), + import("@/lib/jobs/reminder/register-maintenance"), + ]); + returned.push( + ...(await integration.registerIntegrationSyncQueues(target)), + ...(await status.registerStatusQueues(target)), + ...(await rollup.registerRollupQueues(target)), + ...(await reminders.registerReminderQueues(target)), + ...(await maintenance.registerMaintenanceQueues(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); + }); +}); From b2ebcb198d273f652302962f3ec3bf430f8d411e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 10:46:42 +0200 Subject: [PATCH 03/10] test(integrations): pin what every OAuth client sends and makes of each token-endpoint answer --- .../__tests__/__golden__/oauth-wire.json | 4622 +++++++++++++++++ .../__tests__/oauth-wire-parity.test.ts | 398 ++ 2 files changed, 5020 insertions(+) create mode 100644 src/lib/integrations/oauth-kit/__tests__/__golden__/oauth-wire.json create mode 100644 src/lib/integrations/oauth-kit/__tests__/oauth-wire-parity.test.ts 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..35c077e13 --- /dev/null +++ b/src/lib/integrations/oauth-kit/__tests__/__golden__/oauth-wire.json @@ -0,0 +1,4622 @@ +{ + "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": "SyntaxError", + "message": "Unexpected end of JSON input" + }, + "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": [], + "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": "SyntaxError", + "message": "Unexpected token '<', \"unav\"... is not valid JSON" + }, + "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": [], + "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": "SyntaxError", + "message": "Unexpected end of JSON input" + }, + "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": [], + "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": "SyntaxError", + "message": "Unexpected token '<', \"unav\"... is not valid JSON" + }, + "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": [], + "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": { + "result": null, + "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": { + "result": null, + "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": { + "result": null, + "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": { + "result": null, + "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": { + "result": null, + "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": { + "result": null, + "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__/oauth-wire-parity.test.ts b/src/lib/integrations/oauth-kit/__tests__/oauth-wire-parity.test.ts new file mode 100644 index 000000000..65fca1c16 --- /dev/null +++ b/src/lib/integrations/oauth-kit/__tests__/oauth-wire-parity.test.ts @@ -0,0 +1,398 @@ +/** + * 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 withings = await import("@/lib/withings/client"); + const whoop = await import("@/lib/whoop/client"); + const strava = await import("@/lib/strava/client"); + const oura = await import("@/lib/oura/client"); + const polar = await import("@/lib/polar/client"); + const google = await import("@/lib/google-health/client"); + const fitbit = await import("@/lib/fitbit/client"); + const MOI = "https://moi.ouraring.com/oauth/v2/ext/oauth-anonymous"; + return { + withings: { + answers: WITHINGS_ANSWERS, + redirectUri: withings.getWithingsRedirectUri, + authorize: () => withings.getAuthorizationUrl(STATE, CREDS), + exchange: () => withings.exchangeCode("code-1", CREDS), + refresh: () => withings.refreshAccessToken("refresh-0", CREDS), + }, + whoop: { + answers: STANDARD_ANSWERS, + redirectUri: whoop.getWhoopRedirectUri, + authorize: () => whoop.getAuthorizationUrl(STATE, CREDS), + exchange: () => whoop.exchangeCode("code-1", CREDS), + refresh: () => whoop.refreshAccessToken("refresh-0", CREDS), + }, + strava: { + answers: STANDARD_ANSWERS, + redirectUri: strava.getStravaRedirectUri, + authorize: () => strava.getAuthorizationUrl(STATE, CREDS), + exchange: () => strava.exchangeCode("code-1", CREDS), + refresh: () => strava.refreshAccessToken("refresh-0", CREDS), + extra: { + deauthorize_ok: { + answers: [json(200, { access_token: "access-1" })], + call: () => strava.deauthorize("access-1"), + }, + deauthorize_refused: { + answers: [json(401, { message: "Authorization Error" })], + call: () => strava.deauthorize("access-1"), + }, + }, + }, + oura: { + answers: STANDARD_ANSWERS, + redirectUri: oura.getOuraRedirectUri, + authorize: () => oura.getAuthorizationUrl(STATE, CREDS), + exchange: () => oura.exchangeCode("code-1", CREDS), + refresh: () => oura.refreshAccessToken("refresh-0", CREDS), + extra: { + exchange_from_moi_refused: { + answers: [json(400, { error: "invalid_grant" })], + call: () => oura.exchangeCode("code-1", CREDS, MOI), + }, + exchange_legacy_fallback: { + answers: [json(400, { error: "invalid_request" }), json(200, TOKEN)], + call: () => oura.exchangeCode("code-1", CREDS, null), + }, + exchange_both_refused: { + answers: [ + json(401, { error: "invalid_client" }), + json(400, { error: "invalid_grant" }), + ], + call: () => oura.exchangeCode("code-1", CREDS), + }, + refresh_legacy_fallback: { + answers: [json(404, { error: "not_found" }), json(200, TOKEN)], + call: () => oura.refreshAccessToken("refresh-0", CREDS), + }, + refresh_outage_no_fallback: { + answers: [{ status: 502, body: "" }], + call: () => oura.refreshAccessToken("refresh-0", CREDS), + }, + }, + }, + polar: { + answers: STANDARD_ANSWERS, + redirectUri: polar.getPolarRedirectUri, + authorize: () => polar.getAuthorizationUrl(STATE, CREDS), + exchange: () => polar.exchangeCode("code-1", CREDS), + }, + "google-health": { + answers: STANDARD_ANSWERS, + redirectUri: google.getGoogleHealthRedirectUri, + authorize: () => google.getAuthorizationUrl(STATE, CREDS, CHALLENGE), + exchange: () => google.exchangeCode("code-1", VERIFIER, CREDS), + refresh: () => google.refreshAccessToken("refresh-0", CREDS), + }, + fitbit: { + answers: { + ...STANDARD_ANSWERS, + fitbit_errors: [ + json(400, { + errors: [ + { errorType: "invalid_grant", message: "Refresh token invalid" }, + ], + success: false, + }), + ], + }, + redirectUri: fitbit.getFitbitRedirectUri, + authorize: () => fitbit.getAuthorizationUrl(STATE, CREDS, CHALLENGE), + exchange: () => fitbit.exchangeCode("code-1", VERIFIER, CREDS), + refresh: () => fitbit.refreshAccessToken("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/); + }); +}); From df6e182e0b41d44abb4852a3f052229f998c0ea1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 10:56:03 +0200 Subject: [PATCH 04/10] test(integrations): pin what each provider mapper makes of sanitised wire fixtures --- .../__fixtures__/daily-summaries.json | 94 ++ .../__tests__/__fixtures__/exercise.json | 102 ++ .../__tests__/__fixtures__/rollups.json | 72 + .../__tests__/__fixtures__/samples.json | 105 ++ .../__tests__/__fixtures__/sleep.json | 177 +++ .../__golden__/map-client-helpers.json | 604 ++++++++ .../__golden__/map-daily-summaries.json | 457 ++++++ .../__tests__/__golden__/map-exercise.json | 314 ++++ .../__tests__/__golden__/map-labels.json | 37 + .../__tests__/__golden__/map-rollups.json | 294 ++++ .../__tests__/__golden__/map-samples.json | 693 +++++++++ .../__tests__/__golden__/map-sleep.json | 758 ++++++++++ .../__tests__/map-fixtures.test.ts | 460 ++++++ .../__tests__/__fixtures__/entries.json | 81 + .../__tests__/__golden__/map-entries-url.json | 32 + .../__tests__/__golden__/map-entries.json | 72 + .../__golden__/map-non-array-payloads.json | 7 + .../__tests__/__golden__/map-sha1.json | 11 + .../nightscout/__tests__/map-fixtures.test.ts | 91 ++ .../__fixtures__/cardiovascular-age.json | 7 + .../__tests__/__fixtures__/cycle-phases.json | 65 + .../__fixtures__/daily-activity.json | 48 + .../__tests__/__fixtures__/daily-sleep.json | 18 + .../__tests__/__fixtures__/daily-spo2.json | 29 + .../__tests__/__fixtures__/heartrate.json | 17 + .../__tests__/__fixtures__/readiness.json | 71 + .../__tests__/__fixtures__/resilience.json | 11 + .../oura/__tests__/__fixtures__/sleep.json | 135 ++ .../oura/__tests__/__fixtures__/vo2-max.json | 18 + .../oura/__tests__/__fixtures__/workouts.json | 77 + .../__golden__/map-cardiovascular-age.json | 125 ++ .../__golden__/map-cycle-phases.json | 31 + .../__golden__/map-daily-activity.json | 272 ++++ .../__tests__/__golden__/map-daily-sleep.json | 167 +++ .../__tests__/__golden__/map-daily-spo2.json | 151 ++ .../__tests__/__golden__/map-heartrate.json | 68 + .../__tests__/__golden__/map-readiness.json | 406 ++++++ .../__tests__/__golden__/map-resilience.json | 293 ++++ .../__golden__/map-sleep-retired.json | 11 + .../oura/__tests__/__golden__/map-sleep.json | 1298 +++++++++++++++++ .../__tests__/__golden__/map-vo2-max.json | 138 ++ .../__tests__/__golden__/map-workouts.json | 86 ++ src/lib/oura/__tests__/map-fixtures.test.ts | 199 +++ .../__tests__/__fixtures__/activity.json | 35 + .../__tests__/__fixtures__/cardio-load.json | 14 + .../__tests__/__fixtures__/exercises.json | 53 + .../__fixtures__/nightly-recharge.json | 45 + .../polar/__tests__/__fixtures__/sleep.json | 63 + .../polar/__tests__/__fixtures__/spo2.json | 24 + .../__tests__/__golden__/map-activity.json | 260 ++++ .../__tests__/__golden__/map-cardio-load.json | 128 ++ .../__tests__/__golden__/map-exercises.json | 93 ++ .../__tests__/__golden__/map-helpers.json | 91 ++ .../__golden__/map-nightly-recharge.json | 436 ++++++ .../polar/__tests__/__golden__/map-sleep.json | 811 ++++++++++ .../polar/__tests__/__golden__/map-spo2.json | 32 + src/lib/polar/__tests__/map-fixtures.test.ts | 210 +++ .../__tests__/__fixtures__/activities.json | 166 +++ .../__tests__/__golden__/map-activities.json | 190 +++ .../__tests__/__golden__/map-rate-limit.json | 12 + .../__tests__/__golden__/map-sport.json | 16 + src/lib/strava/__tests__/map-fixtures.test.ts | 160 ++ .../whoop/__tests__/__fixtures__/body.json | 9 + .../whoop/__tests__/__fixtures__/cycle.json | 71 + .../__tests__/__fixtures__/recovery.json | 81 + .../whoop/__tests__/__fixtures__/sleep.json | 139 ++ .../whoop/__tests__/__golden__/map-body.json | 42 + .../whoop/__tests__/__golden__/map-cycle.json | 135 ++ .../__tests__/__golden__/map-recovery.json | 143 ++ .../whoop/__tests__/__golden__/map-sleep.json | 246 ++++ .../whoop/__tests__/__golden__/map-sport.json | 27 + src/lib/whoop/__tests__/map-fixtures.test.ts | 109 ++ 72 files changed, 12043 insertions(+) create mode 100644 src/lib/google-health/__tests__/__fixtures__/daily-summaries.json create mode 100644 src/lib/google-health/__tests__/__fixtures__/exercise.json create mode 100644 src/lib/google-health/__tests__/__fixtures__/rollups.json create mode 100644 src/lib/google-health/__tests__/__fixtures__/samples.json create mode 100644 src/lib/google-health/__tests__/__fixtures__/sleep.json create mode 100644 src/lib/google-health/__tests__/__golden__/map-client-helpers.json create mode 100644 src/lib/google-health/__tests__/__golden__/map-daily-summaries.json create mode 100644 src/lib/google-health/__tests__/__golden__/map-exercise.json create mode 100644 src/lib/google-health/__tests__/__golden__/map-labels.json create mode 100644 src/lib/google-health/__tests__/__golden__/map-rollups.json create mode 100644 src/lib/google-health/__tests__/__golden__/map-samples.json create mode 100644 src/lib/google-health/__tests__/__golden__/map-sleep.json create mode 100644 src/lib/google-health/__tests__/map-fixtures.test.ts create mode 100644 src/lib/nightscout/__tests__/__fixtures__/entries.json create mode 100644 src/lib/nightscout/__tests__/__golden__/map-entries-url.json create mode 100644 src/lib/nightscout/__tests__/__golden__/map-entries.json create mode 100644 src/lib/nightscout/__tests__/__golden__/map-non-array-payloads.json create mode 100644 src/lib/nightscout/__tests__/__golden__/map-sha1.json create mode 100644 src/lib/nightscout/__tests__/map-fixtures.test.ts create mode 100644 src/lib/oura/__tests__/__fixtures__/cardiovascular-age.json create mode 100644 src/lib/oura/__tests__/__fixtures__/cycle-phases.json create mode 100644 src/lib/oura/__tests__/__fixtures__/daily-activity.json create mode 100644 src/lib/oura/__tests__/__fixtures__/daily-sleep.json create mode 100644 src/lib/oura/__tests__/__fixtures__/daily-spo2.json create mode 100644 src/lib/oura/__tests__/__fixtures__/heartrate.json create mode 100644 src/lib/oura/__tests__/__fixtures__/readiness.json create mode 100644 src/lib/oura/__tests__/__fixtures__/resilience.json create mode 100644 src/lib/oura/__tests__/__fixtures__/sleep.json create mode 100644 src/lib/oura/__tests__/__fixtures__/vo2-max.json create mode 100644 src/lib/oura/__tests__/__fixtures__/workouts.json create mode 100644 src/lib/oura/__tests__/__golden__/map-cardiovascular-age.json create mode 100644 src/lib/oura/__tests__/__golden__/map-cycle-phases.json create mode 100644 src/lib/oura/__tests__/__golden__/map-daily-activity.json create mode 100644 src/lib/oura/__tests__/__golden__/map-daily-sleep.json create mode 100644 src/lib/oura/__tests__/__golden__/map-daily-spo2.json create mode 100644 src/lib/oura/__tests__/__golden__/map-heartrate.json create mode 100644 src/lib/oura/__tests__/__golden__/map-readiness.json create mode 100644 src/lib/oura/__tests__/__golden__/map-resilience.json create mode 100644 src/lib/oura/__tests__/__golden__/map-sleep-retired.json create mode 100644 src/lib/oura/__tests__/__golden__/map-sleep.json create mode 100644 src/lib/oura/__tests__/__golden__/map-vo2-max.json create mode 100644 src/lib/oura/__tests__/__golden__/map-workouts.json create mode 100644 src/lib/oura/__tests__/map-fixtures.test.ts create mode 100644 src/lib/polar/__tests__/__fixtures__/activity.json create mode 100644 src/lib/polar/__tests__/__fixtures__/cardio-load.json create mode 100644 src/lib/polar/__tests__/__fixtures__/exercises.json create mode 100644 src/lib/polar/__tests__/__fixtures__/nightly-recharge.json create mode 100644 src/lib/polar/__tests__/__fixtures__/sleep.json create mode 100644 src/lib/polar/__tests__/__fixtures__/spo2.json create mode 100644 src/lib/polar/__tests__/__golden__/map-activity.json create mode 100644 src/lib/polar/__tests__/__golden__/map-cardio-load.json create mode 100644 src/lib/polar/__tests__/__golden__/map-exercises.json create mode 100644 src/lib/polar/__tests__/__golden__/map-helpers.json create mode 100644 src/lib/polar/__tests__/__golden__/map-nightly-recharge.json create mode 100644 src/lib/polar/__tests__/__golden__/map-sleep.json create mode 100644 src/lib/polar/__tests__/__golden__/map-spo2.json create mode 100644 src/lib/polar/__tests__/map-fixtures.test.ts create mode 100644 src/lib/strava/__tests__/__fixtures__/activities.json create mode 100644 src/lib/strava/__tests__/__golden__/map-activities.json create mode 100644 src/lib/strava/__tests__/__golden__/map-rate-limit.json create mode 100644 src/lib/strava/__tests__/__golden__/map-sport.json create mode 100644 src/lib/strava/__tests__/map-fixtures.test.ts create mode 100644 src/lib/whoop/__tests__/__fixtures__/body.json create mode 100644 src/lib/whoop/__tests__/__fixtures__/cycle.json create mode 100644 src/lib/whoop/__tests__/__fixtures__/recovery.json create mode 100644 src/lib/whoop/__tests__/__fixtures__/sleep.json create mode 100644 src/lib/whoop/__tests__/__golden__/map-body.json create mode 100644 src/lib/whoop/__tests__/__golden__/map-cycle.json create mode 100644 src/lib/whoop/__tests__/__golden__/map-recovery.json create mode 100644 src/lib/whoop/__tests__/__golden__/map-sleep.json create mode 100644 src/lib/whoop/__tests__/__golden__/map-sport.json create mode 100644 src/lib/whoop/__tests__/map-fixtures.test.ts 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__/map-fixtures.test.ts b/src/lib/google-health/__tests__/map-fixtures.test.ts new file mode 100644 index 000000000..4ddd7320f --- /dev/null +++ b/src/lib/google-health/__tests__/map-fixtures.test.ts @@ -0,0 +1,460 @@ +/** + * 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, + extractGoogleApiErrorDetail, + formatCivilBound, + googleHealthRetryDelayMs, + incrementalFilter, + refusesEdgeFilter, + resolveGoogleHealthUserId, +} from "../client"; +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/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/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__/map-fixtures.test.ts b/src/lib/oura/__tests__/map-fixtures.test.ts new file mode 100644 index 000000000..aa808dbae --- /dev/null +++ b/src/lib/oura/__tests__/map-fixtures.test.ts @@ -0,0 +1,199 @@ +/** + * 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, + type OuraCardiovascularAge, + type OuraCyclePhase, + type OuraDailyActivity, + type OuraDailySleep, + type OuraDailySpo2, + type OuraHeartRateSample, + type OuraReadiness, + type OuraResilience, + type OuraSleep, + type OuraVo2Max, + type OuraWorkout, +} from "../client"; +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/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__/map-fixtures.test.ts b/src/lib/polar/__tests__/map-fixtures.test.ts new file mode 100644 index 000000000..1cbe10126 --- /dev/null +++ b/src/lib/polar/__tests__/map-fixtures.test.ts @@ -0,0 +1,210 @@ +/** + * 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, + type PolarActivity, + type PolarCardioLoad, + type PolarExercise, + type PolarNightlyRecharge, + type PolarSleep, + type PolarSpo2, +} from "../client"; +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/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__/map-fixtures.test.ts b/src/lib/strava/__tests__/map-fixtures.test.ts new file mode 100644 index 000000000..954ba650b --- /dev/null +++ b/src/lib/strava/__tests__/map-fixtures.test.ts @@ -0,0 +1,160 @@ +/** + * 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 { + mapActivity, + stravaRateLimitRetryAfterSeconds, + summaryHasHeartRate, + type StravaDetailedActivity, + type StravaSummaryActivity, +} from "../client"; +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/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__/map-fixtures.test.ts b/src/lib/whoop/__tests__/map-fixtures.test.ts new file mode 100644 index 000000000..590fae371 --- /dev/null +++ b/src/lib/whoop/__tests__/map-fixtures.test.ts @@ -0,0 +1,109 @@ +/** + * 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, + type WhoopBodyMeasurement, + type WhoopCycle, + type WhoopRecovery, + type WhoopSleep, +} from "../client"; +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", + ); + }); +}); From 4807a70c1d6e233729019bf8d87e02aea52a48cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 12:36:09 +0200 Subject: [PATCH 05/10] refactor(jobs): declare every queue in a typed registry and load handlers on first job The five hand-written registrars (about 5,500 lines, one 997-line function) are replaced by `jobs/registry/`: one `defineJob` entry per queue with its cron, schedule send options, policy, work options, lock, lane and a handler loader. `registerJobGroup` creates, schedules, retires and binds each group in the order the registrars used; the worker boot walks the groups and runs their boot discovery. Handlers load on their queue's first job. The facts a declaration needs at boot (names, crons, concurrency, send options) moved out of the heavy job modules into light `*-queue.ts` siblings, and their importers point there, so loading the registry evaluates 288 modules instead of the 1,164 the registrars pulled in. The formerly inline handlers moved verbatim into handler modules under `jobs/reminder/`. Fitbit's queues live in `fitbit/jobs/definitions.ts` and enter the registry through one spread, so removing the integration later is that folder and that line. `tests/integration/queue-registration-parity.test.ts` proves pg-boss holds exactly what the registrars left (queues, schedules, bindings, byte for byte) and loads every handler once. The queue tests and the runtime guard assert on the registry objects instead of reading registrar source. --- .../baselines/source-file-lines.json | 21 +- .../baselines/source-function-lines.json | 11 +- .../baselines/test-source-reads.json | 22 +- .../baselines/version-stamp-comments.json | 42 +- src/__tests__/guard-floor-guard.test.ts | 2 - src/__tests__/helpers/source-metrics.ts | 42 +- src/__tests__/job-queue-runtime-guard.test.ts | 305 +-- src/__tests__/moodlog-removal-guard.test.ts | 12 +- src/app/api/admin/backups/route.ts | 2 +- src/app/api/admin/encryption/rotate/route.ts | 2 +- .../admin/import-apple-health-export/route.ts | 2 +- src/app/api/fitbit/callback/route.ts | 2 +- src/app/api/google-health/callback/route.ts | 2 +- .../api/import/apple-health-export/route.ts | 2 +- .../api/import/health-connect-export/route.ts | 2 +- .../intake/import/__tests__/route.test.ts | 6 +- src/app/api/oura/callback/route.ts | 6 +- src/app/api/strava/callback/route.ts | 2 +- src/app/api/whoop/callback/route.ts | 2 +- src/lib/fitbit/jobs/definitions.ts | 98 + src/lib/fitbit/jobs/queues.ts | 30 + .../apple-health-import-legacy-bridge.test.ts | 2 +- .../apple-health-import-reconcile.test.ts | 51 +- .../apple-health-import-send-policy.test.ts | 47 +- .../coach-memory-refresh-queue.test.ts | 58 +- .../coach-message-cleanup-queue.test.ts | 68 +- .../cumulative-pr-rederive-queue.test.ts | 64 +- .../__tests__/cycle-reminder-queue.test.ts | 80 +- src/lib/jobs/__tests__/daily-briefing.test.ts | 96 +- .../jobs/__tests__/data-arrival-queue.test.ts | 86 +- .../__tests__/data-backup-send-policy.test.ts | 16 +- .../jobs/__tests__/document-ai-run.test.ts | 2 +- src/lib/jobs/__tests__/document-index.test.ts | 7 +- .../document-summary-catchup.test.ts | 2 +- .../__tests__/drain-cumulative-queue.test.ts | 173 +- src/lib/jobs/__tests__/fitbit-queues.test.ts | 152 +- src/lib/jobs/__tests__/geo-backfill.test.ts | 35 +- .../__tests__/geolite2-fetch-queue.test.ts | 70 +- .../__tests__/google-health-queues.test.ts | 128 +- .../__tests__/insight-pregenerate.test.ts | 51 +- .../__tests__/insight-status-generate.test.ts | 48 +- .../jobs/__tests__/intake-auto-skip.test.ts | 6 +- .../jobs/__tests__/intake-auto-take.test.ts | 6 +- .../__tests__/intake-slot-dedup-queue.test.ts | 161 +- .../integration-backfill-admission.test.ts | 188 +- .../__tests__/mcp-token-cleanup-queue.test.ts | 66 +- .../mean-consolidation-queue.test.ts | 62 +- .../measurement-reminder-queue.test.ts | 115 +- .../measurement-tombstone-cleanup.test.ts | 59 +- .../__tests__/mood-prognosis-refresh.test.ts | 6 +- .../jobs/__tests__/nightscout-queues.test.ts | 114 +- .../__tests__/period-narrative-warm.test.ts | 41 +- .../__tests__/queue-policy-tables.test.ts | 184 +- .../__tests__/read-side-effect-queues.test.ts | 69 +- .../__tests__/recovery-score-queue.test.ts | 54 +- src/lib/jobs/__tests__/restore-drill.test.ts | 5 +- .../__tests__/sleep-timeline-queues.test.ts | 82 +- .../__tests__/status-cron-candidates.test.ts | 47 +- .../step-consolidation-repair-queue.test.ts | 67 +- .../stress-strain-retention-queue.test.ts | 289 ++- .../jobs/__tests__/subsystem-surface.test.ts | 109 +- .../__tests__/tls-pin-monitor-queue.test.ts | 73 +- src/lib/jobs/__tests__/whoop-queues.test.ts | 173 +- .../__tests__/withings-ecg-worker.test.ts | 34 +- .../jobs/__tests__/withings-queues.test.ts | 105 +- .../__tests__/workout-insight-queue.test.ts | 51 +- .../jobs/achievement-unlock-sweep-queue.ts | 8 + src/lib/jobs/achievement-unlock-sweep.ts | 4 - src/lib/jobs/apple-health-import-queue.ts | 36 + src/lib/jobs/apple-health-import-worker.ts | 39 +- src/lib/jobs/backup-restore-queue.ts | 6 + src/lib/jobs/backup-restore.ts | 3 +- src/lib/jobs/coach-nudge-queue.ts | 10 + src/lib/jobs/coach-nudge.ts | 5 - src/lib/jobs/coach-reminder-sweep-queue.ts | 10 + src/lib/jobs/coach-reminder-sweep.ts | 5 - .../jobs/compaction-tombstone-purge-queue.ts | 6 + src/lib/jobs/compaction-tombstone-purge.ts | 3 +- src/lib/jobs/daily-briefing-queue.ts | 9 + src/lib/jobs/daily-briefing.ts | 4 - src/lib/jobs/dawarich-sync-queue.ts | 9 + src/lib/jobs/dawarich-sync.ts | 6 +- .../dense-intraday-hourly-rebuild-queue.ts | 14 + src/lib/jobs/dense-intraday-hourly-rebuild.ts | 11 +- .../jobs/dense-intraday-retention-queue.ts | 14 + src/lib/jobs/dense-intraday-retention.ts | 11 +- src/lib/jobs/document-ai-run-queue.ts | 9 + src/lib/jobs/document-ai-run-reaper-queue.ts | 9 + src/lib/jobs/document-ai-run-reaper.ts | 4 - src/lib/jobs/document-ai-run.ts | 6 +- .../document-content-index-backfill-queue.ts | 9 + .../jobs/document-content-index-backfill.ts | 6 +- src/lib/jobs/document-index-queue.ts | 9 + src/lib/jobs/document-index.ts | 6 +- .../jobs/document-summary-catchup-queue.ts | 9 + src/lib/jobs/document-summary-catchup.ts | 6 +- src/lib/jobs/document-summary-queue.ts | 9 + src/lib/jobs/document-summary-reaper-queue.ts | 9 + src/lib/jobs/document-summary-reaper.ts | 4 - src/lib/jobs/document-summary.ts | 6 +- .../jobs/document-thumbnail-backfill-queue.ts | 9 + src/lib/jobs/document-thumbnail-backfill.ts | 6 +- src/lib/jobs/document-thumbnail-queue.ts | 9 + src/lib/jobs/document-thumbnail.ts | 6 +- src/lib/jobs/drain-cumulative-queue.ts | 20 + src/lib/jobs/encryption-key-rotate-queue.ts | 8 + src/lib/jobs/encryption-key-rotate.ts | 3 - src/lib/jobs/fitbit-backfill.ts | 10 +- src/lib/jobs/fitbit-sleep-repair.ts | 10 +- src/lib/jobs/google-health-backfill-queue.ts | 13 + src/lib/jobs/google-health-backfill.ts | 10 +- .../jobs/google-health-sleep-repair-queue.ts | 13 + src/lib/jobs/google-health-sleep-repair.ts | 10 +- src/lib/jobs/health-connect-import-queue.ts | 9 + src/lib/jobs/health-connect-import-worker.ts | 5 - src/lib/jobs/insight-pregenerate-shared.ts | 7 + src/lib/jobs/insight-pregenerate.ts | 7 - src/lib/jobs/insight-status-generate-queue.ts | 13 + .../jobs/insight-status-generate-shared.ts | 10 +- src/lib/jobs/insight-status-generate.ts | 6 +- src/lib/jobs/intake-auto-skip-queue.ts | 13 + src/lib/jobs/intake-auto-skip.ts | 9 - src/lib/jobs/intake-auto-take-queue.ts | 12 + src/lib/jobs/intake-auto-take.ts | 8 - src/lib/jobs/integration-backfill-forward.ts | 27 + src/lib/jobs/integration-poll-queues.ts | 88 +- src/lib/jobs/integration-watchdog-queue.ts | 18 + src/lib/jobs/integration-watchdog.ts | 12 - src/lib/jobs/measurement-fold-repair-queue.ts | 6 + src/lib/jobs/measurement-fold-repair.ts | 9 +- .../jobs/medication-intake-import-queue.ts | 8 + src/lib/jobs/medication-intake-import.ts | 3 - src/lib/jobs/medication-low-stock-queue.ts | 14 + src/lib/jobs/medication-low-stock.ts | 9 - src/lib/jobs/mood-prognosis-refresh-queue.ts | 8 + src/lib/jobs/mood-prognosis-refresh.ts | 4 - src/lib/jobs/offhost-backup-queue.ts | 27 + src/lib/jobs/offhost-backup.ts | 23 +- src/lib/jobs/offhost-purge-queue.ts | 9 + src/lib/jobs/offhost-purge.ts | 5 +- src/lib/jobs/oura-backfill-queue.ts | 9 + src/lib/jobs/oura-backfill.ts | 6 +- src/lib/jobs/period-narrative-queue.ts | 24 + src/lib/jobs/period-narrative-shared.ts | 24 +- src/lib/jobs/period-narrative-warm.ts | 6 +- src/lib/jobs/queue-runtime.ts | 52 +- src/lib/jobs/recovery-score-queue.ts | 14 + src/lib/jobs/recovery-score.ts | 10 - .../__tests__/registry-contract.test.ts | 129 + .../registry/__tests__/registry-helpers.ts | 61 + src/lib/jobs/registry/define-job.ts | 109 + src/lib/jobs/registry/index.ts | 55 + src/lib/jobs/registry/integration-sync.ts | 391 +++ .../jobs/registry/maintenance-discovery.ts | 100 + src/lib/jobs/registry/maintenance.ts | 744 ++++++ src/lib/jobs/registry/register.ts | 146 ++ src/lib/jobs/registry/reminders.ts | 70 + src/lib/jobs/registry/rollup.ts | 281 +++ src/lib/jobs/registry/status.ts | 260 ++ src/lib/jobs/reminder-worker.ts | 109 +- .../jobs/reminder/climb-backfill-at-boot.ts | 34 + src/lib/jobs/reminder/document-handlers.ts | 233 ++ .../reminder/encryption-backfill-handlers.ts | 214 ++ src/lib/jobs/reminder/maintenance-handlers.ts | 186 ++ .../reminder/register-integration-sync.ts | 1051 -------- src/lib/jobs/reminder/register-maintenance.ts | 2197 ----------------- src/lib/jobs/reminder/register-reminders.ts | 180 -- src/lib/jobs/reminder/register-rollup.ts | 1156 --------- src/lib/jobs/reminder/register-status.ts | 878 ------- src/lib/jobs/reminder/rollup-handlers.ts | 582 +++++ src/lib/jobs/reminder/status-handlers.ts | 394 +++ src/lib/jobs/restore-drill-queue.ts | 23 + src/lib/jobs/restore-drill.ts | 19 +- src/lib/jobs/sleep-timeline-backfill-queue.ts | 13 + src/lib/jobs/sleep-timeline-backfill.ts | 10 +- src/lib/jobs/strain-score-queue.ts | 13 + src/lib/jobs/strain-score.ts | 9 - src/lib/jobs/strava-backfill-queue.ts | 13 + src/lib/jobs/strava-backfill.ts | 10 +- src/lib/jobs/stress-score-queue.ts | 13 + src/lib/jobs/stress-score.ts | 9 - src/lib/jobs/tls-pin-monitor-queue.ts | 15 + src/lib/jobs/tls-pin-monitor.ts | 10 - src/lib/jobs/whoop-backfill-queue.ts | 13 + src/lib/jobs/whoop-backfill.ts | 10 +- .../medications/intake-import-admission.ts | 2 +- src/lib/support/report.ts | 8 +- src/lib/whoop/webhook-handler.ts | 11 +- src/lib/withings/webhook-handler.ts | 6 +- tests/integration/backup-restore-job.test.ts | 2 +- .../integration/health-connect-import.test.ts | 2 +- .../queue-registration-parity.test.ts | 31 +- .../auth-me-documents-auto-ai-read.test.ts | 6 +- 193 files changed, 6830 insertions(+), 7950 deletions(-) create mode 100644 src/lib/fitbit/jobs/definitions.ts create mode 100644 src/lib/fitbit/jobs/queues.ts create mode 100644 src/lib/jobs/achievement-unlock-sweep-queue.ts create mode 100644 src/lib/jobs/apple-health-import-queue.ts create mode 100644 src/lib/jobs/backup-restore-queue.ts create mode 100644 src/lib/jobs/coach-nudge-queue.ts create mode 100644 src/lib/jobs/coach-reminder-sweep-queue.ts create mode 100644 src/lib/jobs/compaction-tombstone-purge-queue.ts create mode 100644 src/lib/jobs/daily-briefing-queue.ts create mode 100644 src/lib/jobs/dawarich-sync-queue.ts create mode 100644 src/lib/jobs/dense-intraday-hourly-rebuild-queue.ts create mode 100644 src/lib/jobs/dense-intraday-retention-queue.ts create mode 100644 src/lib/jobs/document-ai-run-queue.ts create mode 100644 src/lib/jobs/document-ai-run-reaper-queue.ts create mode 100644 src/lib/jobs/document-content-index-backfill-queue.ts create mode 100644 src/lib/jobs/document-index-queue.ts create mode 100644 src/lib/jobs/document-summary-catchup-queue.ts create mode 100644 src/lib/jobs/document-summary-queue.ts create mode 100644 src/lib/jobs/document-summary-reaper-queue.ts create mode 100644 src/lib/jobs/document-thumbnail-backfill-queue.ts create mode 100644 src/lib/jobs/document-thumbnail-queue.ts create mode 100644 src/lib/jobs/drain-cumulative-queue.ts create mode 100644 src/lib/jobs/encryption-key-rotate-queue.ts create mode 100644 src/lib/jobs/google-health-backfill-queue.ts create mode 100644 src/lib/jobs/google-health-sleep-repair-queue.ts create mode 100644 src/lib/jobs/health-connect-import-queue.ts create mode 100644 src/lib/jobs/insight-status-generate-queue.ts create mode 100644 src/lib/jobs/intake-auto-skip-queue.ts create mode 100644 src/lib/jobs/intake-auto-take-queue.ts create mode 100644 src/lib/jobs/integration-backfill-forward.ts create mode 100644 src/lib/jobs/integration-watchdog-queue.ts create mode 100644 src/lib/jobs/measurement-fold-repair-queue.ts create mode 100644 src/lib/jobs/medication-intake-import-queue.ts create mode 100644 src/lib/jobs/medication-low-stock-queue.ts create mode 100644 src/lib/jobs/mood-prognosis-refresh-queue.ts create mode 100644 src/lib/jobs/offhost-backup-queue.ts create mode 100644 src/lib/jobs/offhost-purge-queue.ts create mode 100644 src/lib/jobs/oura-backfill-queue.ts create mode 100644 src/lib/jobs/period-narrative-queue.ts create mode 100644 src/lib/jobs/recovery-score-queue.ts create mode 100644 src/lib/jobs/registry/__tests__/registry-contract.test.ts create mode 100644 src/lib/jobs/registry/__tests__/registry-helpers.ts create mode 100644 src/lib/jobs/registry/define-job.ts create mode 100644 src/lib/jobs/registry/index.ts create mode 100644 src/lib/jobs/registry/integration-sync.ts create mode 100644 src/lib/jobs/registry/maintenance-discovery.ts create mode 100644 src/lib/jobs/registry/maintenance.ts create mode 100644 src/lib/jobs/registry/register.ts create mode 100644 src/lib/jobs/registry/reminders.ts create mode 100644 src/lib/jobs/registry/rollup.ts create mode 100644 src/lib/jobs/registry/status.ts create mode 100644 src/lib/jobs/reminder/climb-backfill-at-boot.ts create mode 100644 src/lib/jobs/reminder/document-handlers.ts create mode 100644 src/lib/jobs/reminder/encryption-backfill-handlers.ts create mode 100644 src/lib/jobs/reminder/maintenance-handlers.ts delete mode 100644 src/lib/jobs/reminder/register-integration-sync.ts delete mode 100644 src/lib/jobs/reminder/register-maintenance.ts delete mode 100644 src/lib/jobs/reminder/register-reminders.ts delete mode 100644 src/lib/jobs/reminder/register-rollup.ts delete mode 100644 src/lib/jobs/reminder/register-status.ts create mode 100644 src/lib/jobs/reminder/rollup-handlers.ts create mode 100644 src/lib/jobs/reminder/status-handlers.ts create mode 100644 src/lib/jobs/restore-drill-queue.ts create mode 100644 src/lib/jobs/sleep-timeline-backfill-queue.ts create mode 100644 src/lib/jobs/strain-score-queue.ts create mode 100644 src/lib/jobs/strava-backfill-queue.ts create mode 100644 src/lib/jobs/stress-score-queue.ts create mode 100644 src/lib/jobs/tls-pin-monitor-queue.ts create mode 100644 src/lib/jobs/whoop-backfill-queue.ts diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index ae77487b9..0dba141af 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": 121, - "total": 143770, + "count": 116, + "total": 137648, "entries": { "app/api/dashboard/summary/route.ts": 1032, "app/api/documents/inbound/route.ts": 811, @@ -89,18 +89,13 @@ "lib/insights/mood-status.ts": 814, "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, @@ -117,7 +112,7 @@ "lib/rollups/measurement-rollups.ts": 1496, "lib/search/search-records.ts": 996, "lib/sharing/grants.ts": 1046, - "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, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index dd571a095..7cc32eb6d 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": 410, - "total": 147795, + "count": 403, + "total": 144626, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -368,13 +368,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, diff --git a/src/__tests__/baselines/test-source-reads.json b/src/__tests__/baselines/test-source-reads.json index 2aff0e4d8..309eef2f2 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": 367, - "total": 367, + "count": 349, + "total": 349, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 1, "__tests__/admin-data-wipe-completeness.test.ts": 1, @@ -314,44 +314,26 @@ "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 65ad892a5..d98d3866f 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": 2954, - "total": 10499, + "count": 2924, + "total": 10176, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -2288,59 +2288,34 @@ "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, @@ -2396,7 +2371,7 @@ "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, @@ -2410,11 +2385,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, 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__/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/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..0e089baff 100644 --- a/src/app/api/fitbit/callback/route.ts +++ b/src/app/api/fitbit/callback/route.ts @@ -15,7 +15,7 @@ import { } from "@/lib/fitbit/client"; 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"; diff --git a/src/app/api/google-health/callback/route.ts b/src/app/api/google-health/callback/route.ts index 4c0ac3d98..21a3638e4 100644 --- a/src/app/api/google-health/callback/route.ts +++ b/src/app/api/google-health/callback/route.ts @@ -17,7 +17,7 @@ import { } from "@/lib/google-health/client"; 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"; 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/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/route.ts b/src/app/api/oura/callback/route.ts index 26b2e677e..350cdaf5c 100644 --- a/src/app/api/oura/callback/route.ts +++ b/src/app/api/oura/callback/route.ts @@ -27,10 +27,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"; /** diff --git a/src/app/api/strava/callback/route.ts b/src/app/api/strava/callback/route.ts index e94544c3c..0bf259bf9 100644 --- a/src/app/api/strava/callback/route.ts +++ b/src/app/api/strava/callback/route.ts @@ -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"; diff --git a/src/app/api/whoop/callback/route.ts b/src/app/api/whoop/callback/route.ts index 9bf1cef17..036c54565 100644 --- a/src/app/api/whoop/callback/route.ts +++ b/src/app/api/whoop/callback/route.ts @@ -14,7 +14,7 @@ import { 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"; diff --git a/src/lib/fitbit/jobs/definitions.ts b/src/lib/fitbit/jobs/definitions.ts new file mode 100644 index 000000000..a4308c82d --- /dev/null +++ b/src/lib/fitbit/jobs/definitions.ts @@ -0,0 +1,98 @@ +/** + * 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("@/lib/jobs/reminder/fitbit-sync")).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("@/lib/jobs/reminder/fitbit-sync")) + .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("@/lib/jobs/fitbit-backfill") + ).enqueueBootTimeFitbitBackfill(bootStaggerSecondsFor("fitbit-backfill")), + }, + { + name: "fitbit-sleep-repair", + run: async () => + ( + await import("@/lib/jobs/fitbit-sleep-repair") + ).enqueueBootTimeFitbitSleepRepair( + bootStaggerSecondsFor("fitbit-sleep-repair"), + ), + }, +]; 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/__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 index a9e9fbbc6..988c8fcf8 100644 --- a/src/lib/jobs/__tests__/fitbit-queues.test.ts +++ b/src/lib/jobs/__tests__/fitbit-queues.test.ts @@ -1,12 +1,12 @@ /** - * v1.12.0 — pg-boss queue registration for the Fitbit / Google Health sync layer. + * pg-boss queue registration for the Fitbit 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 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 @@ -17,81 +17,101 @@ 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"); +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/jobs/reminder/fitbit-sync"; +import { cronIsTheRetry } from "@/lib/jobs/reminder/registrar-shared"; +import { + discoveryOf, + groupOf, + jobOf, + loadedHandler, + recordingBoss, +} from "@/lib/jobs/registry/__tests__/registry-helpers"; -const FITBIT_QUEUE_CONSTS = [ - "FITBIT_SYNC_QUEUE", - "FITBIT_BACKFILL_QUEUE", - "FITBIT_SLEEP_REPAIR_QUEUE", - "FITBIT_OAUTH_STATE_CLEANUP_QUEUE", +const handlerSource = readFileSync( + join(__dirname, "..", "reminder", "fitbit-sync.ts"), + "utf8", +); + +const FITBIT_QUEUES = [ + FITBIT_SYNC_QUEUE, + FITBIT_BACKFILL_QUEUE, + FITBIT_SLEEP_REPAIR_QUEUE, + FITBIT_OAUTH_STATE_CLEANUP_QUEUE, ] as const; -describe("reminder-worker — Fitbit sync queues", () => { +/** 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(source).toMatch(/FITBIT_SYNC_QUEUE\s*=\s*["']fitbit-sync["']/); - expect(source).toMatch( - /FITBIT_OAUTH_STATE_CLEANUP_QUEUE\s*=\s*["']fitbit-oauth-state-cleanup["']/, - ); + expect(FITBIT_SYNC_QUEUE).toBe("fitbit-sync"); + expect(FITBIT_OAUTH_STATE_CLEANUP_QUEUE).toBe("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("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(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*\]/, - ); + 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("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("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 Fitbit backfill boot discovery", () => { - expect(source).toMatch( - /enqueueBootTimeFitbitBackfill\(\s*bootStaggerSecondsFor\("fitbit-backfill"\)/, - ); + 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("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["']/); + 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__/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-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__/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..69ac042a1 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/jobs/fitbit-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/jobs/fitbit-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,12 +79,29 @@ 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", @@ -32,7 +116,7 @@ const providerSources = [ ].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/fitbit-backfill.ts b/src/lib/jobs/fitbit-backfill.ts index ad457bfae..02f84fa21 100644 --- a/src/lib/jobs/fitbit-backfill.ts +++ b/src/lib/jobs/fitbit-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 "./../fitbit/jobs/queues"; export interface FitbitBackfillPayload { userId: string; diff --git a/src/lib/jobs/fitbit-sleep-repair.ts b/src/lib/jobs/fitbit-sleep-repair.ts index 0b681ff1b..f79ebb8f2 100644 --- a/src/lib/jobs/fitbit-sleep-repair.ts +++ b/src/lib/jobs/fitbit-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 "./../fitbit/jobs/queues"; export interface FitbitSleepRepairPayload { userId: string; 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..94d44bb9e 100644 --- a/src/lib/jobs/google-health-backfill.ts +++ b/src/lib/jobs/google-health-backfill.ts @@ -44,15 +44,7 @@ import { 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-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..2adbfa5d5 100644 --- a/src/lib/jobs/sleep-timeline-backfill.ts +++ b/src/lib/jobs/sleep-timeline-backfill.ts @@ -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/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/webhook-handler.ts b/src/lib/whoop/webhook-handler.ts index a815cabba..a3c05e829 100644 --- a/src/lib/whoop/webhook-handler.ts +++ b/src/lib/whoop/webhook-handler.ts @@ -34,6 +34,11 @@ 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_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/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/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/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/queue-registration-parity.test.ts b/tests/integration/queue-registration-parity.test.ts index 40ff5d72b..544432dc0 100644 --- a/tests/integration/queue-registration-parity.test.ts +++ b/tests/integration/queue-registration-parity.test.ts @@ -71,21 +71,8 @@ function recordingBoss(real: PgBoss): PgBoss { } async function registerEveryQueue(target: PgBoss): Promise { - const [integration, status, rollup, reminders, maintenance] = - await Promise.all([ - import("@/lib/jobs/reminder/register-integration-sync"), - import("@/lib/jobs/reminder/register-status"), - import("@/lib/jobs/reminder/register-rollup"), - import("@/lib/jobs/reminder/register-reminders"), - import("@/lib/jobs/reminder/register-maintenance"), - ]); - returned.push( - ...(await integration.registerIntegrationSyncQueues(target)), - ...(await status.registerStatusQueues(target)), - ...(await rollup.registerRollupQueues(target)), - ...(await reminders.registerReminderQueues(target)), - ...(await maintenance.registerMaintenanceQueues(target)), - ); + const { registerAllJobGroups } = await import("@/lib/jobs/registry/index"); + returned.push(...(await registerAllJobGroups(target))); } beforeAll(async () => { @@ -142,4 +129,18 @@ describe("queue registration", () => { // 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"; From b6f45c0d882882c9fe7008fbef17b6fe5afea5a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 12:57:47 +0200 Subject: [PATCH 06/10] refactor(integrations): move every provider OAuth client onto one factory `oauth-kit/client.ts` builds a provider's consent URL, code exchange, refresh and deauthorization from a declaration of what differs: endpoints and scope, client authentication (form or HTTP Basic, with or without the client id beside it), the fields a grant adds, how an answer is classified and the refusal named, the error type, and where the token sits in the body. The seven hand-written copies of the redirect URI, authorization URL, token post, Basic header and PKCE pair are gone; each provider keeps an `oauth.ts` with its declaration. Shared behaviour is now uniform: a 2xx without a token object (an empty or non-JSON body) is a transient `empty_token_body` failure for every provider. WHOOP, Strava and Google Health used to return null, and Withings threw an unclassified SyntaxError; the OAuth wire golden records exactly these lines changing and nothing else. Oura's live/legacy endpoint fallback, Google's invalid_grant lift, Strava's Fault summary and Fitbit's errorType are declared per provider. --- .../baselines/source-file-lines.json | 13 +- .../baselines/version-stamp-comments.json | 9 +- .../env-empty-is-unset-guard.test.ts | 13 +- src/__tests__/oauth-kit-guard.test.ts | 45 ++- src/app/api/fitbit/callback/route.ts | 13 +- .../fitbit/connect/__tests__/route.test.ts | 13 +- src/app/api/fitbit/connect/route.ts | 5 +- src/app/api/google-health/callback/route.ts | 18 +- src/app/api/integrations/status/route.ts | 2 +- .../api/oura/callback/__tests__/route.test.ts | 12 +- src/app/api/oura/callback/route.ts | 9 +- .../polar/callback/__tests__/route.test.ts | 4 +- src/app/api/polar/callback/route.ts | 5 +- .../strava/callback/__tests__/route.test.ts | 4 +- src/app/api/strava/callback/route.ts | 4 +- .../whoop/callback/__tests__/route.test.ts | 18 +- src/app/api/whoop/callback/route.ts | 9 +- .../withings/callback/__tests__/route.test.ts | 20 +- src/app/api/withings/callback/route.ts | 4 +- src/app/api/withings/status/route.ts | 2 +- src/lib/fitbit/__tests__/client.test.ts | 36 +- .../__tests__/setup-docs-parity.test.ts | 4 +- .../fitbit/__tests__/sync-dead-token.test.ts | 4 +- .../__tests__/sync-plausibility.test.ts | 8 +- .../__tests__/sync-upsert-resurrect.test.ts | 8 +- src/lib/fitbit/__tests__/sync.test.ts | 9 +- src/lib/fitbit/client.ts | 267 -------------- src/lib/fitbit/credentials.ts | 2 +- src/lib/fitbit/oauth.ts | 78 ++++ src/lib/fitbit/sync-core.ts | 4 +- .../google-health/__tests__/scopes.test.ts | 2 +- .../__tests__/setup-docs-parity.test.ts | 2 +- .../__tests__/sync-failsoft.test.ts | 12 +- .../__tests__/sync-plausibility.test.ts | 8 +- .../__tests__/sync-sleep-replace.test.ts | 8 +- .../__tests__/sync-upsert-resurrect.test.ts | 9 +- src/lib/google-health/client.ts | 286 -------------- src/lib/google-health/credentials.ts | 2 +- src/lib/google-health/oauth-descriptor.ts | 4 +- src/lib/google-health/oauth.ts | 105 ++++++ .../__tests__/redirect-uri-empty-env.test.ts | 36 +- src/lib/integrations/callback-urls.ts | 28 +- .../integrations/connection-health-load.ts | 2 +- .../__tests__/__golden__/oauth-wire.json | 148 +++++++- .../disconnect-shared-account.test.ts | 4 +- .../__tests__/oauth-wire-parity.test.ts | 84 ++--- src/lib/integrations/oauth-kit/client.ts | 348 ++++++++++++++++++ .../integrations/oauth-kit/connect-start.ts | 30 +- src/lib/integrations/oauth-kit/disconnect.ts | 4 +- src/lib/integrations/oauth-kit/pkce.ts | 20 + .../integrations/oauth-kit/redirect-uri.ts | 88 +++++ .../__tests__/fitbit-sleep-repair.test.ts | 8 +- .../google-health-sleep-repair.test.ts | 9 +- src/lib/oura/__tests__/client.test.ts | 44 +-- src/lib/oura/__tests__/credentials.test.ts | 2 +- src/lib/oura/__tests__/sync.test.ts | 8 +- src/lib/oura/client.ts | 283 +------------- src/lib/oura/credentials.ts | 2 +- src/lib/oura/oauth-descriptor.ts | 4 +- src/lib/oura/oauth.ts | 137 +++++++ src/lib/polar/__tests__/client.test.ts | 19 +- src/lib/polar/__tests__/credentials.test.ts | 2 +- src/lib/polar/client.ts | 123 +------ src/lib/polar/credentials.ts | 2 +- src/lib/polar/oauth.ts | 64 ++++ .../strava/__tests__/token-refusal.test.ts | 16 +- src/lib/strava/client.ts | 173 +-------- src/lib/strava/credentials.ts | 2 +- src/lib/strava/oauth-descriptor.ts | 4 +- src/lib/strava/oauth.ts | 79 ++++ src/lib/whoop/__tests__/client.test.ts | 14 +- .../whoop/__tests__/sync-dead-token.test.ts | 9 +- src/lib/whoop/__tests__/sync.test.ts | 11 +- src/lib/whoop/client.ts | 134 +------ src/lib/whoop/credentials.ts | 2 +- src/lib/whoop/oauth-descriptor.ts | 4 +- src/lib/whoop/oauth.ts | 54 +++ src/lib/withings/__tests__/client.test.ts | 19 +- .../__tests__/subscription-state.test.ts | 8 +- .../__tests__/sync-measure-watermark.test.ts | 11 +- .../__tests__/sync-plausibility.test.ts | 8 +- src/lib/withings/client.ts | 175 +-------- src/lib/withings/credentials.ts | 2 +- src/lib/withings/oauth-descriptor.ts | 4 +- src/lib/withings/oauth.ts | 108 ++++++ src/lib/withings/sync-activity.ts | 7 +- src/lib/withings/sync-sleep.ts | 2 +- .../oura-refresh-serialization.test.ts | 8 +- ...ovider-token-refresh-serialization.test.ts | 21 +- 89 files changed, 1690 insertions(+), 1764 deletions(-) create mode 100644 src/lib/fitbit/oauth.ts create mode 100644 src/lib/google-health/oauth.ts create mode 100644 src/lib/integrations/oauth-kit/client.ts create mode 100644 src/lib/integrations/oauth-kit/pkce.ts create mode 100644 src/lib/integrations/oauth-kit/redirect-uri.ts create mode 100644 src/lib/oura/oauth.ts create mode 100644 src/lib/polar/oauth.ts create mode 100644 src/lib/strava/oauth.ts create mode 100644 src/lib/whoop/oauth.ts create mode 100644 src/lib/withings/oauth.ts diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index bfd995c15..4b039dc0a 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": 117, - "total": 138503, + "count": 116, + "total": 136645, "entries": { "app/api/dashboard/summary/route.ts": 1032, "app/api/documents/inbound/route.ts": 811, @@ -69,11 +69,11 @@ "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/client.ts": 1262, "lib/fitbit/sync-core.ts": 968, "lib/gamification/achievements-result.ts": 1094, "lib/gamification/achievements.ts": 913, - "lib/google-health/client.ts": 1875, + "lib/google-health/client.ts": 1589, "lib/google-health/mappers.ts": 1295, "lib/google-health/sync-core.ts": 1634, "lib/idempotency.ts": 879, @@ -107,16 +107,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/client.ts": 1089, "lib/oura/sync.ts": 902, - "lib/polar/client.ts": 1010, + "lib/polar/client.ts": 893, "lib/rollups/measurement-rollups.ts": 1496, "lib/search/search-records.ts": 996, "lib/sharing/grants.ts": 1046, "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, "proxy.ts": 870 diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 3a4766a7f..7f96efd81 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": 2924, - "total": 10177, + "count": 2921, + "total": 10172, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -2686,7 +2686,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, @@ -2698,7 +2697,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, @@ -2800,7 +2798,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, @@ -2915,7 +2912,7 @@ "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, 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__/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/app/api/fitbit/callback/route.ts b/src/app/api/fitbit/callback/route.ts index 0e089baff..57e99199d 100644 --- a/src/app/api/fitbit/callback/route.ts +++ b/src/app/api/fitbit/callback/route.ts @@ -7,12 +7,9 @@ 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/fitbit/jobs/queues"; @@ -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 21a3638e4..97947aa72 100644 --- a/src/app/api/google-health/callback/route.ts +++ b/src/app/api/google-health/callback/route.ts @@ -10,11 +10,11 @@ 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-queue"; @@ -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/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/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 350cdaf5c..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, @@ -137,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 0bf259bf9..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, @@ -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 036c54565..36da98e8b 100644 --- a/src/app/api/whoop/callback/route.ts +++ b/src/app/api/whoop/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, - 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"; @@ -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__/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..23e68bdfe 100644 --- a/src/lib/fitbit/__tests__/sync-dead-token.test.ts +++ b/src/lib/fitbit/__tests__/sync-dead-token.test.ts @@ -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..1ba60d6fa 100644 --- a/src/lib/fitbit/__tests__/sync-plausibility.test.ts +++ b/src/lib/fitbit/__tests__/sync-plausibility.test.ts @@ -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-upsert-resurrect.test.ts b/src/lib/fitbit/__tests__/sync-upsert-resurrect.test.ts index 508bf739c..7a3298379 100644 --- a/src/lib/fitbit/__tests__/sync-upsert-resurrect.test.ts +++ b/src/lib/fitbit/__tests__/sync-upsert-resurrect.test.ts @@ -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.test.ts b/src/lib/fitbit/__tests__/sync.test.ts index 9d8990a3b..effbc1506 100644 --- a/src/lib/fitbit/__tests__/sync.test.ts +++ b/src/lib/fitbit/__tests__/sync.test.ts @@ -55,11 +55,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/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..2a2d6d57f 100644 --- a/src/lib/fitbit/sync-core.ts +++ b/src/lib/fitbit/sync-core.ts @@ -20,7 +20,7 @@ import { collapseToTypeDayKeys, recomputeBucketsForKeys, } from "@/lib/rollups/measurement-rollups"; -import { refreshAccessToken } from "./client"; +import { fitbitOAuth } from "./oauth"; import { isFitbitRetired } from "./retirement"; import { getUserFitbitCredentials } from "./credentials"; import { FitbitApiError, classifyFitbitError } from "./response-classifier"; @@ -115,7 +115,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 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-failsoft.test.ts b/src/lib/google-health/__tests__/sync-failsoft.test.ts index 64c09bee9..d9684e8e7 100644 --- a/src/lib/google-health/__tests__/sync-failsoft.test.ts +++ b/src/lib/google-health/__tests__/sync-failsoft.test.ts @@ -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-plausibility.test.ts b/src/lib/google-health/__tests__/sync-plausibility.test.ts index 2fb5b3404..7b97e05c8 100644 --- a/src/lib/google-health/__tests__/sync-plausibility.test.ts +++ b/src/lib/google-health/__tests__/sync-plausibility.test.ts @@ -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-replace.test.ts b/src/lib/google-health/__tests__/sync-sleep-replace.test.ts index c7302712c..bb5380027 100644 --- a/src/lib/google-health/__tests__/sync-sleep-replace.test.ts +++ b/src/lib/google-health/__tests__/sync-sleep-replace.test.ts @@ -32,7 +32,13 @@ vi.mock("@/lib/insights/comprehensive-generate", () => ({ 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-upsert-resurrect.test.ts b/src/lib/google-health/__tests__/sync-upsert-resurrect.test.ts index 0704e346c..56b640bde 100644 --- a/src/lib/google-health/__tests__/sync-upsert-resurrect.test.ts +++ b/src/lib/google-health/__tests__/sync-upsert-resurrect.test.ts @@ -45,9 +45,12 @@ vi.mock("@/lib/insights/comprehensive-generate", () => ({ 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/client.ts b/src/lib/google-health/client.ts index dbdd331b3..b731f9597 100644 --- a/src/lib/google-health/client.ts +++ b/src/lib/google-health/client.ts @@ -34,14 +34,12 @@ * module graph acyclic (`client` → `mappers`, never back). */ 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 { wallClockInTz } from "@/lib/tz/wall-clock"; import { GoogleHealthApiError, classifyGoogleHealthResponse, - type GoogleHealthClassification, } from "./response-classifier"; import { googleHealthSampleInstant, @@ -49,7 +47,6 @@ import { type GoogleHealthDataType, type GoogleHealthRollupPoint, } from "./mappers"; -import { envValue } from "@/lib/env"; import { GOOGLE_HEALTH_NO_PROGRESS_PAGES, googleHealthWalkProgress, @@ -327,289 +324,6 @@ export function noteGoogleHealthDeferred(): void { } 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", - ); -} /** * The Google Health profile carries the external user id used as the 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/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/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 index 35c077e13..0b3d3b29d 100644 --- a/src/lib/integrations/oauth-kit/__tests__/__golden__/oauth-wire.json +++ b/src/lib/integrations/oauth-kit/__tests__/__golden__/oauth-wire.json @@ -57,8 +57,11 @@ }, "empty_body": { "error": { - "name": "SyntaxError", - "message": "Unexpected end of JSON input" + "name": "WithingsApiError", + "message": "Withings token error: ?", + "classification": "transient", + "reason": "no_status_field", + "verb": "token" }, "requests": [ { @@ -79,7 +82,20 @@ "body": null } ], - "events": [], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "withings", + "method": "exchangeCode", + "duration_ms": 0, + "status": 200, + "error": "no_status_field" + } + ] + } + ], "unansweredScript": 0 }, "invalid_grant": { @@ -260,8 +276,11 @@ }, "outage": { "error": { - "name": "SyntaxError", - "message": "Unexpected token '<', \"unav\"... is not valid JSON" + "name": "WithingsApiError", + "message": "Withings token error: ?", + "classification": "transient", + "reason": "http_503", + "verb": "token" }, "requests": [ { @@ -282,7 +301,20 @@ "body": null } ], - "events": [], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "withings", + "method": "exchangeCode", + "duration_ms": 0, + "status": 503, + "error": "http_503" + } + ] + } + ], "unansweredScript": 0 } }, @@ -331,8 +363,11 @@ }, "empty_body": { "error": { - "name": "SyntaxError", - "message": "Unexpected end of JSON input" + "name": "WithingsApiError", + "message": "Withings refresh error: ?", + "classification": "transient", + "reason": "no_status_field", + "verb": "refresh" }, "requests": [ { @@ -352,7 +387,20 @@ "body": null } ], - "events": [], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "withings", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 200, + "error": "no_status_field" + } + ] + } + ], "unansweredScript": 0 }, "invalid_grant": { @@ -529,8 +577,11 @@ }, "outage": { "error": { - "name": "SyntaxError", - "message": "Unexpected token '<', \"unav\"... is not valid JSON" + "name": "WithingsApiError", + "message": "Withings refresh error: ?", + "classification": "transient", + "reason": "http_503", + "verb": "refresh" }, "requests": [ { @@ -550,7 +601,20 @@ "body": null } ], - "events": [], + "events": [ + { + "method": "addExternalCall", + "args": [ + { + "service": "withings", + "method": "refreshAccessToken", + "duration_ms": 0, + "status": 503, + "error": "http_503" + } + ] + } + ], "unansweredScript": 0 } } @@ -610,7 +674,15 @@ "unansweredScript": 0 }, "empty_body": { - "result": null, + "error": { + "name": "WhoopApiError", + "message": "WHOOP exchangeCode error: 200", + "classification": "transient", + "httpStatus": 200, + "reason": "empty_token_body", + "verb": "exchangeCode", + "vendor": "WHOOP" + }, "requests": [ { "method": "POST", @@ -911,7 +983,15 @@ "unansweredScript": 0 }, "empty_body": { - "result": null, + "error": { + "name": "WhoopApiError", + "message": "WHOOP refreshAccessToken error: 200", + "classification": "transient", + "httpStatus": 200, + "reason": "empty_token_body", + "verb": "refreshAccessToken", + "vendor": "WHOOP" + }, "requests": [ { "method": "POST", @@ -1226,7 +1306,15 @@ "unansweredScript": 0 }, "empty_body": { - "result": null, + "error": { + "name": "StravaApiError", + "message": "Strava exchangeCode error: 200", + "classification": "transient", + "httpStatus": 200, + "reason": "empty_token_body", + "verb": "exchangeCode", + "vendor": "Strava" + }, "requests": [ { "method": "POST", @@ -1528,7 +1616,15 @@ "unansweredScript": 0 }, "empty_body": { - "result": null, + "error": { + "name": "StravaApiError", + "message": "Strava refreshAccessToken error: 200", + "classification": "transient", + "httpStatus": 200, + "reason": "empty_token_body", + "verb": "refreshAccessToken", + "vendor": "Strava" + }, "requests": [ { "method": "POST", @@ -3345,7 +3441,15 @@ "unansweredScript": 0 }, "empty_body": { - "result": null, + "error": { + "name": "GoogleHealthApiError", + "message": "GoogleHealth exchangeCode error: 200", + "classification": "transient", + "httpStatus": 200, + "reason": "empty_token_body", + "verb": "exchangeCode", + "vendor": "GoogleHealth" + }, "requests": [ { "method": "POST", @@ -3651,7 +3755,15 @@ "unansweredScript": 0 }, "empty_body": { - "result": null, + "error": { + "name": "GoogleHealthApiError", + "message": "GoogleHealth refreshAccessToken error: 200", + "classification": "transient", + "httpStatus": 200, + "reason": "empty_token_body", + "verb": "refreshAccessToken", + "vendor": "GoogleHealth" + }, "requests": [ { "method": "POST", 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 index 65fca1c16..fdc5b0fa7 100644 --- a/src/lib/integrations/oauth-kit/__tests__/oauth-wire-parity.test.ts +++ b/src/lib/integrations/oauth-kit/__tests__/oauth-wire-parity.test.ts @@ -167,90 +167,92 @@ interface ProviderUnderTest { * file that follows the clients' API; the golden does not. */ async function providers(): Promise> { - const withings = await import("@/lib/withings/client"); - const whoop = await import("@/lib/whoop/client"); - const strava = await import("@/lib/strava/client"); - const oura = await import("@/lib/oura/client"); - const polar = await import("@/lib/polar/client"); - const google = await import("@/lib/google-health/client"); - const fitbit = await import("@/lib/fitbit/client"); + 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: withings.getWithingsRedirectUri, - authorize: () => withings.getAuthorizationUrl(STATE, CREDS), - exchange: () => withings.exchangeCode("code-1", CREDS), - refresh: () => withings.refreshAccessToken("refresh-0", CREDS), + 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: whoop.getWhoopRedirectUri, - authorize: () => whoop.getAuthorizationUrl(STATE, CREDS), - exchange: () => whoop.exchangeCode("code-1", CREDS), - refresh: () => whoop.refreshAccessToken("refresh-0", CREDS), + 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: strava.getStravaRedirectUri, - authorize: () => strava.getAuthorizationUrl(STATE, CREDS), - exchange: () => strava.exchangeCode("code-1", CREDS), - refresh: () => strava.refreshAccessToken("refresh-0", CREDS), + 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: () => strava.deauthorize("access-1"), + call: () => stravaOAuth.deauthorize("access-1"), }, deauthorize_refused: { answers: [json(401, { message: "Authorization Error" })], - call: () => strava.deauthorize("access-1"), + call: () => stravaOAuth.deauthorize("access-1"), }, }, }, oura: { answers: STANDARD_ANSWERS, - redirectUri: oura.getOuraRedirectUri, - authorize: () => oura.getAuthorizationUrl(STATE, CREDS), - exchange: () => oura.exchangeCode("code-1", CREDS), - refresh: () => oura.refreshAccessToken("refresh-0", CREDS), + 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: () => oura.exchangeCode("code-1", CREDS, MOI), + call: () => exchangeOuraCode("code-1", CREDS, MOI), }, exchange_legacy_fallback: { answers: [json(400, { error: "invalid_request" }), json(200, TOKEN)], - call: () => oura.exchangeCode("code-1", CREDS, null), + call: () => exchangeOuraCode("code-1", CREDS, null), }, exchange_both_refused: { answers: [ json(401, { error: "invalid_client" }), json(400, { error: "invalid_grant" }), ], - call: () => oura.exchangeCode("code-1", CREDS), + call: () => exchangeOuraCode("code-1", CREDS), }, refresh_legacy_fallback: { answers: [json(404, { error: "not_found" }), json(200, TOKEN)], - call: () => oura.refreshAccessToken("refresh-0", CREDS), + call: () => ouraOAuth.refresh("refresh-0", CREDS), }, refresh_outage_no_fallback: { answers: [{ status: 502, body: "" }], - call: () => oura.refreshAccessToken("refresh-0", CREDS), + call: () => ouraOAuth.refresh("refresh-0", CREDS), }, }, }, polar: { answers: STANDARD_ANSWERS, - redirectUri: polar.getPolarRedirectUri, - authorize: () => polar.getAuthorizationUrl(STATE, CREDS), - exchange: () => polar.exchangeCode("code-1", CREDS), + redirectUri: polarOAuth.redirectUri, + authorize: () => polarOAuth.authorizationUrl(STATE, CREDS), + exchange: () => polarOAuth.exchange("code-1", CREDS), }, "google-health": { answers: STANDARD_ANSWERS, - redirectUri: google.getGoogleHealthRedirectUri, - authorize: () => google.getAuthorizationUrl(STATE, CREDS, CHALLENGE), - exchange: () => google.exchangeCode("code-1", VERIFIER, CREDS), - refresh: () => google.refreshAccessToken("refresh-0", CREDS), + redirectUri: googleHealthOAuth.redirectUri, + authorize: () => + googleHealthOAuth.authorizationUrl(STATE, CREDS, CHALLENGE), + exchange: () => googleHealthOAuth.exchange("code-1", CREDS, pkce), + refresh: () => googleHealthOAuth.refresh("refresh-0", CREDS), }, fitbit: { answers: { @@ -264,10 +266,10 @@ async function providers(): Promise> { }), ], }, - redirectUri: fitbit.getFitbitRedirectUri, - authorize: () => fitbit.getAuthorizationUrl(STATE, CREDS, CHALLENGE), - exchange: () => fitbit.exchangeCode("code-1", VERIFIER, CREDS), - refresh: () => fitbit.refreshAccessToken("refresh-0", CREDS), + redirectUri: fitbitOAuth.redirectUri, + authorize: () => fitbitOAuth.authorizationUrl(STATE, CREDS, CHALLENGE), + exchange: () => fitbitOAuth.exchange("code-1", CREDS, pkce), + refresh: () => fitbitOAuth.refresh("refresh-0", CREDS), }, }; } 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/jobs/__tests__/fitbit-sleep-repair.test.ts b/src/lib/jobs/__tests__/fitbit-sleep-repair.test.ts index f768c9799..bbb619a11 100644 --- a/src/lib/jobs/__tests__/fitbit-sleep-repair.test.ts +++ b/src/lib/jobs/__tests__/fitbit-sleep-repair.test.ts @@ -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, 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..cf6c36c81 100644 --- a/src/lib/jobs/__tests__/google-health-sleep-repair.test.ts +++ b/src/lib/jobs/__tests__/google-health-sleep-repair.test.ts @@ -70,10 +70,13 @@ vi.mock("@/lib/insights/comprehensive-generate", () => ({ 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/oura/__tests__/client.test.ts b/src/lib/oura/__tests__/client.test.ts index 46d800995..e5715afd2 100644 --- a/src/lib/oura/__tests__/client.test.ts +++ b/src/lib/oura/__tests__/client.test.ts @@ -1,19 +1,13 @@ 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, mapCardiovascularAge, mapDailyActivity, mapDailySleep, @@ -22,7 +16,6 @@ import { mapResilience, mapSleep, mapVo2Max, - refreshAccessToken, type OuraCardiovascularAge, type OuraDailyActivity, type OuraDailySleep, @@ -32,7 +25,14 @@ import { type OuraSleep, type OuraVo2Max, } from "../client"; +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 +75,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 +92,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 +104,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 +112,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 +122,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 +130,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 +153,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 +169,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 +183,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 +193,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 +210,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 +224,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 +232,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__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index cd2de619b..6c9401d76 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -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/client.ts b/src/lib/oura/client.ts index 2e3ac2569..c2561df67 100644 --- a/src/lib/oura/client.ts +++ b/src/lib/oura/client.ts @@ -1,294 +1,23 @@ /** - * v1.17.0 — Oura Cloud API v2 client for OAuth + data fetching. + * Oura Cloud API v2 client: collection reads and mappers. * 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`. + * Hand-rolled fetch over `safeFetch` (no SDK): typed v2 collection fetchers + * with `next_token` pagination and a single source-of-truth field→Measurement + * mapping. Collections are read by `start_date` / `end_date` (YYYY-MM-DD). + * 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"; 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 }`. */ 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/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/polar/__tests__/client.test.ts b/src/lib/polar/__tests__/client.test.ts index 97f3c0702..99617d6d3 100644 --- a/src/lib/polar/__tests__/client.test.ts +++ b/src/lib/polar/__tests__/client.test.ts @@ -7,16 +7,11 @@ vi.mock("@/lib/logging/context", () => ({ })); import { - POLAR_OAUTH_SCOPE, - exchangeCode, fetchActivities, fetchCardioLoads, fetchNightlyRecharges, fetchSleeps, fetchSpo2, - getAuthorizationUrl, - getPolarCredentials, - getPolarRedirectUri, mapActivity, mapCardioLoad, mapNightlyRecharge, @@ -29,7 +24,9 @@ import { type PolarSleep, type PolarSpo2, } from "../client"; +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 +79,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 +90,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 +108,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 +127,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 +137,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/client.ts b/src/lib/polar/client.ts index 042aa8183..4eb151cb9 100644 --- a/src/lib/polar/client.ts +++ b/src/lib/polar/client.ts @@ -1,14 +1,12 @@ /** - * v1.17.0 — Polar AccessLink v3 client for OAuth + data fetching. + * Polar AccessLink v3 client: user registration, collection reads, mappers. * 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): typed collection fetchers and + * per-resource field→Measurement mappers. The OAuth handshake is `./oauth.ts`. * * Polar specifics that differ from WHOOP: * - The token endpoint uses HTTP Basic auth (`base64(client_id:client_secret)`) @@ -31,124 +29,9 @@ 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"; 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 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/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/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..6b5fd33a8 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 client: 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): typed collection + * fetchers and a single field → `Workout` mapper. The OAuth handshake is + * `./oauth.ts`. * * Strava specifics: * - It is a WORKOUT source only — the API is activity-centric and exposes no @@ -25,173 +24,11 @@ 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 { StravaApiError, classifyStravaResponse } from "./response-classifier"; import { mapStravaSportType } from "./sport-map"; import type { WorkoutSportType } from "@/lib/validations/workout"; -import { envOr, envValue } from "@/lib/env"; 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) ─────────────────────────────────────── 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/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/whoop/__tests__/client.test.ts b/src/lib/whoop/__tests__/client.test.ts index fa0db0f1b..b935e8a2c 100644 --- a/src/lib/whoop/__tests__/client.test.ts +++ b/src/lib/whoop/__tests__/client.test.ts @@ -2,9 +2,7 @@ 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 +13,16 @@ import { fetchRecoveries, fetchSleeps, fetchWorkouts, - getAuthorizationUrl, mapCycle, mapRecovery, mapSleep, - refreshAccessToken, type WhoopCycle, type WhoopRecovery, type WhoopSleep, } from "../client"; +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 +48,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 +74,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 +87,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 +99,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__/sync-dead-token.test.ts b/src/lib/whoop/__tests__/sync-dead-token.test.ts index a5d51027e..a5fadba59 100644 --- a/src/lib/whoop/__tests__/sync-dead-token.test.ts +++ b/src/lib/whoop/__tests__/sync-dead-token.test.ts @@ -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.test.ts b/src/lib/whoop/__tests__/sync.test.ts index 85462399e..f989d22a0 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), }; }); diff --git a/src/lib/whoop/client.ts b/src/lib/whoop/client.ts index 17d2aa529..70f3aa859 100644 --- a/src/lib/whoop/client.ts +++ b/src/lib/whoop/client.ts @@ -2,12 +2,10 @@ * WHOOP API v2 client for OAuth and data fetching. * 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`). + * Hand-rolled fetch over `safeFetch` (no SDK): 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`). + * The OAuth handshake is `./oauth.ts`. * * 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 @@ -19,138 +17,14 @@ 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"; 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 }`. */ 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/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/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..cd6718b60 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 () => ({ diff --git a/src/lib/withings/__tests__/sync-measure-watermark.test.ts b/src/lib/withings/__tests__/sync-measure-watermark.test.ts index 4408c931d..1a3397c9b 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", @@ -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({ diff --git a/src/lib/withings/__tests__/sync-plausibility.test.ts b/src/lib/withings/__tests__/sync-plausibility.test.ts index b80a6715c..3f4a5e4c9 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 () => ({ 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/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-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"; From e626f2123ff922bd7bc3844c47a785a490435b9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 13:12:23 +0200 Subject: [PATCH 07/10] refactor(integrations): run one write step after every provider sync `integrations/sync-kit/after-write.ts` is the tail every provider write ends in: the arrivals go out, the touched days are refolded, the read caches are marked stale back to the earliest touched reading, and the touched status cards are re-warmed (through `afterMeasurementMutation`, shared with the interactive routes); Withings also evicts the dashboard snapshot. A full-history backfill refolds its whole touched range once through `afterProviderBackfill`. Withings, WHOOP, Polar, Nightscout, Google Health, Fitbit and Oura drop their hand-written tails, and no provider module imports the comprehensive insight generator any more; a guard holds the provider folders to that. Differences from the copies: the status cards are re-warmed even when the fold failed, and the caches are marked stale back to the earliest touched reading instead of wholesale, so older closed blocks survive a sync. Several provider unit tests mocked an export the old tails never reached (their failures were caught and only logged); the mocks now name what the code calls. --- .../baselines/source-file-lines.json | 10 +- .../baselines/source-function-lines.json | 11 +- .../baselines/version-stamp-comments.json | 4 +- .../measurement-write-tail-guard.test.ts | 33 +++-- .../provider-ai-boundary-guard.test.ts | 70 +++++++++ src/lib/fitbit/__tests__/poll-cohort.test.ts | 2 +- .../fitbit/__tests__/sync-dead-token.test.ts | 2 +- .../__tests__/sync-plausibility.test.ts | 2 +- .../__tests__/sync-sleep-replace.test.ts | 2 +- .../__tests__/sync-upsert-resurrect.test.ts | 2 +- .../fitbit/__tests__/sync-user-fitbit.test.ts | 2 +- src/lib/fitbit/__tests__/sync.test.ts | 3 +- src/lib/fitbit/sync-core.ts | 27 +--- src/lib/fitbit/sync.ts | 23 +-- .../sync-deferred-rollup-ledger.test.ts | 2 +- .../__tests__/sync-failsoft.test.ts | 2 +- .../__tests__/sync-fairness.test.ts | 2 +- .../sync-intraday-daily-wide.test.ts | 2 +- .../__tests__/sync-outcomes.test.ts | 2 +- .../__tests__/sync-plausibility.test.ts | 2 +- .../__tests__/sync-sleep-replace.test.ts | 2 +- .../__tests__/sync-time-budget.test.ts | 2 +- .../__tests__/sync-upsert-resurrect.test.ts | 2 +- src/lib/google-health/sync-core.ts | 33 ++--- src/lib/google-health/sync.ts | 24 +-- src/lib/integrations/sync-kit/after-write.ts | 140 ++++++++++++++++++ .../__tests__/fitbit-sleep-repair.test.ts | 2 +- .../google-health-sleep-repair.test.ts | 2 +- .../__tests__/sync-plausibility.test.ts | 3 +- src/lib/nightscout/__tests__/sync.test.ts | 2 +- src/lib/nightscout/sync.ts | 38 +---- src/lib/oura/__tests__/sync.test.ts | 2 +- .../__tests__/workouts-heart-rate.test.ts | 2 +- src/lib/oura/sync.ts | 28 ++-- src/lib/polar/__tests__/sync.test.ts | 3 +- src/lib/polar/sync.ts | 37 +---- src/lib/rollups/after-measurement-mutation.ts | 14 +- .../whoop/__tests__/sync-dead-token.test.ts | 2 +- .../whoop/__tests__/sync-user-whoop.test.ts | 2 +- src/lib/whoop/__tests__/sync.test.ts | 2 +- src/lib/whoop/sync-core.ts | 45 ++---- .../__tests__/subscription-state.test.ts | 2 +- src/lib/withings/__tests__/sync-ecg.test.ts | 4 - .../__tests__/sync-measure-watermark.test.ts | 13 +- src/lib/withings/__tests__/sync-plan.test.ts | 2 +- .../__tests__/sync-plausibility.test.ts | 2 +- src/lib/withings/sync.ts | 67 ++------- .../integration/provider-after-write.test.ts | 99 +++++++++++++ 48 files changed, 474 insertions(+), 307 deletions(-) create mode 100644 src/__tests__/provider-ai-boundary-guard.test.ts create mode 100644 src/lib/integrations/sync-kit/after-write.ts create mode 100644 tests/integration/provider-after-write.test.ts diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 4b039dc0a..728d41357 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": 116, - "total": 136645, + "total": 136568, "entries": { "app/api/dashboard/summary/route.ts": 1032, "app/api/documents/inbound/route.ts": 811, @@ -70,12 +70,12 @@ "lib/export/coach-backup.ts": 1070, "lib/export/profile-backup.ts": 1019, "lib/fitbit/client.ts": 1262, - "lib/fitbit/sync-core.ts": 968, + "lib/fitbit/sync-core.ts": 951, "lib/gamification/achievements-result.ts": 1094, "lib/gamification/achievements.ts": 913, "lib/google-health/client.ts": 1589, "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, @@ -108,7 +108,7 @@ "lib/medications/scheduling/slot-upsert.ts": 926, "lib/notifications/senders/apns.ts": 1100, "lib/oura/client.ts": 1089, - "lib/oura/sync.ts": 902, + "lib/oura/sync.ts": 900, "lib/polar/client.ts": 893, "lib/rollups/measurement-rollups.ts": 1496, "lib/search/search-records.ts": 996, @@ -117,7 +117,7 @@ "lib/telegram-webhook-handlers.ts": 1341, "lib/validations/inbound-documents.ts": 1130, "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 7dbef3259..d7f5955f3 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": 403, - "total": 144691, + "count": 402, + "total": 144428, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -324,12 +324,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/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, @@ -404,7 +403,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/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 7f96efd81..c65b301db 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": 2921, - "total": 10172, + "total": 10169, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -2918,7 +2918,7 @@ "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__/measurement-write-tail-guard.test.ts b/src/__tests__/measurement-write-tail-guard.test.ts index 1faa22114..471e3299d 100644 --- a/src/__tests__/measurement-write-tail-guard.test.ts +++ b/src/__tests__/measurement-write-tail-guard.test.ts @@ -32,6 +32,13 @@ const WRITE_RE = /** Runs `afterMeasurementMutation` on the rows it wrote. */ const TAIL = "runs afterMeasurementMutation"; +/** + * 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 @@ -57,17 +64,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": @@ -168,6 +171,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__/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/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__/sync-dead-token.test.ts b/src/lib/fitbit/__tests__/sync-dead-token.test.ts index 23e68bdfe..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", () => ({ diff --git a/src/lib/fitbit/__tests__/sync-plausibility.test.ts b/src/lib/fitbit/__tests__/sync-plausibility.test.ts index 1ba60d6fa..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", () => ({ 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 7a3298379..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", () => ({ 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 effbc1506..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 () => {}), })); diff --git a/src/lib/fitbit/sync-core.ts b/src/lib/fitbit/sync-core.ts index 2a2d6d57f..210f55c96 100644 --- a/src/lib/fitbit/sync-core.ts +++ b/src/lib/fitbit/sync-core.ts @@ -10,16 +10,13 @@ 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 { 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"; @@ -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__/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 d9684e8e7..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", () => ({ 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-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 7b97e05c8..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", () => ({ 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 bb5380027..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,7 +26,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", () => ({ 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..0ed7cebb0 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 })); 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 56b640bde..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,7 +39,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", () => ({ diff --git a/src/lib/google-health/sync-core.ts b/src/lib/google-health/sync-core.ts index 282a3a112..e32e3b5f9 100644 --- a/src/lib/google-health/sync-core.ts +++ b/src/lib/google-health/sync-core.ts @@ -38,11 +38,8 @@ 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 { googleHealthOutcomeTruncated, googleHealthSliceExhausted, @@ -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.ts b/src/lib/google-health/sync.ts index 0caff675a..b26f32416 100644 --- a/src/lib/google-health/sync.ts +++ b/src/lib/google-health/sync.ts @@ -2,11 +2,7 @@ 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 { @@ -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/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__/fitbit-sleep-repair.test.ts b/src/lib/jobs/__tests__/fitbit-sleep-repair.test.ts index bbb619a11..23922aee7 100644 --- a/src/lib/jobs/__tests__/fitbit-sleep-repair.test.ts +++ b/src/lib/jobs/__tests__/fitbit-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", () => ({ 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 cf6c36c81..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,7 +64,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/google-health/credentials", () => ({ 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__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index 6c9401d76..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, })); diff --git a/src/lib/oura/__tests__/workouts-heart-rate.test.ts b/src/lib/oura/__tests__/workouts-heart-rate.test.ts index 7460e99f1..9d2bd56ea 100644 --- a/src/lib/oura/__tests__/workouts-heart-rate.test.ts +++ b/src/lib/oura/__tests__/workouts-heart-rate.test.ts @@ -33,7 +33,7 @@ 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 () => {}), })); diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index fe4f89984..804b15e06 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 { @@ -885,17 +883,17 @@ 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: arrivals (unless the caller emits its own), 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 afterProviderWrite({ + userId, + provider: "oura", + touched, + ...(opts.emitArrivals !== false + ? { arrivals: { rows: insertedRows, source: "oura" as const } } + : {}), }); return imported; 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/sync.ts b/src/lib/polar/sync.ts index 080108b79..f48aafcb1 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, @@ -441,26 +434,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/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/whoop/__tests__/sync-dead-token.test.ts b/src/lib/whoop/__tests__/sync-dead-token.test.ts index a5fadba59..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", () => ({ 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.test.ts b/src/lib/whoop/__tests__/sync.test.ts index f989d22a0..e05221176 100644 --- a/src/lib/whoop/__tests__/sync.test.ts +++ b/src/lib/whoop/__tests__/sync.test.ts @@ -108,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/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/withings/__tests__/subscription-state.test.ts b/src/lib/withings/__tests__/subscription-state.test.ts index cd6718b60..079a2ca6f 100644 --- a/src/lib/withings/__tests__/subscription-state.test.ts +++ b/src/lib/withings/__tests__/subscription-state.test.ts @@ -70,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 1a3397c9b..ff2317418 100644 --- a/src/lib/withings/__tests__/sync-measure-watermark.test.ts +++ b/src/lib/withings/__tests__/sync-measure-watermark.test.ts @@ -74,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), })); @@ -253,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([ { @@ -273,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 3f4a5e4c9..60901ebb6 100644 --- a/src/lib/withings/__tests__/sync-plausibility.test.ts +++ b/src/lib/withings/__tests__/sync-plausibility.test.ts @@ -76,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/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/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); + }); +}); From 7e54b6be817a72a81cc1cea7823762d5c01dd4b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 13:30:10 +0200 Subject: [PATCH 08/10] refactor(integrations): split the provider clients into wire types, endpoints and mappers Oura, Polar, WHOOP and Strava each keep the transport and endpoints in `client.ts` and gain `wire-types.ts` (the record shapes) and `map.ts` (the pure record-to-row mappers, no I/O). Google Health's 1,600-line client becomes `budget.ts` (slice budget and outcome tally), `request.ts` (error detail, retry, rate limits), `data-points.ts` (the paginated walker) and a small `client.ts` for the profile and roll-up reads; its `export * from "./mappers"` facade is gone and its importers name the module that defines what they use. The code moved verbatim; the mapper goldens written before the move and the provider integration tests pass unchanged. --- .../baselines/source-file-lines.json | 7 +- .../baselines/source-function-lines.json | 4 +- .../baselines/version-stamp-comments.json | 6 +- .../integrations/google-health/test/route.ts | 8 +- .../__tests__/backfill-bounds.test.ts | 6 +- .../backfill-resume-progress.test.ts | 2 +- .../__tests__/date-filter-fallback.test.ts | 3 +- .../__tests__/edge-filter-fallback.test.ts | 12 +- .../__tests__/map-fixtures.test.ts | 8 +- .../google-health/__tests__/mappers.test.ts | 8 +- .../__tests__/rate-limit-and-token.test.ts | 6 +- .../__tests__/rollup-request.test.ts | 6 +- .../__tests__/sync-activity-failsoft.test.ts | 10 +- .../sync-metrics-dense-pages.test.ts | 6 +- .../__tests__/sync-metrics-failsoft.test.ts | 9 +- .../sync-sleep-morning-refresh.test.ts | 6 +- .../__tests__/sync-time-budget.test.ts | 2 +- .../sync-workout-insert-identity.test.ts | 6 +- .../__tests__/walk-resume-and-retry.test.ts | 8 +- src/lib/google-health/budget.ts | 278 ++++ src/lib/google-health/client.ts | 1232 +---------------- src/lib/google-health/data-points.ts | 565 ++++++++ src/lib/google-health/request.ts | 390 ++++++ src/lib/google-health/sync-activity.ts | 8 +- src/lib/google-health/sync-core.ts | 6 +- src/lib/google-health/sync-metrics.ts | 12 +- src/lib/google-health/sync-sleep.ts | 5 +- src/lib/google-health/sync-workout.ts | 6 +- src/lib/google-health/sync.ts | 2 +- .../__tests__/sleep-score-oura-rhythm.test.ts | 3 +- .../__tests__/google-health-backfill.test.ts | 12 +- src/lib/jobs/google-health-backfill.ts | 2 +- src/lib/oura/__tests__/client.test.ts | 10 +- src/lib/oura/__tests__/map-fixtures.test.ts | 4 +- .../__tests__/workouts-heart-rate.test.ts | 2 +- src/lib/oura/client.ts | 760 +--------- src/lib/oura/cycle-sync.ts | 6 +- src/lib/oura/map.ts | 556 ++++++++ src/lib/oura/sync-heart-rate.ts | 9 +- src/lib/oura/sync-workouts.ts | 2 +- src/lib/oura/sync.ts | 10 +- src/lib/oura/wire-types.ts | 201 +++ .../polar/__tests__/client-exercises.test.ts | 6 +- src/lib/polar/__tests__/client.test.ts | 8 +- src/lib/polar/__tests__/map-fixtures.test.ts | 4 +- src/lib/polar/__tests__/parity.test.ts | 7 +- src/lib/polar/__tests__/sync-workouts.test.ts | 2 +- src/lib/polar/client.ts | 640 +-------- src/lib/polar/map.ts | 536 +++++++ src/lib/polar/sync-workouts.ts | 3 +- src/lib/polar/sync.ts | 4 +- src/lib/polar/wire-types.ts | 103 ++ src/lib/strava/__tests__/client.test.ts | 5 +- src/lib/strava/__tests__/map-fixtures.test.ts | 7 +- src/lib/strava/client.ts | 194 +-- src/lib/strava/map.ts | 152 ++ src/lib/strava/sync.ts | 11 +- src/lib/strava/wire-types.ts | 37 + src/lib/whoop/__tests__/client.test.ts | 10 +- src/lib/whoop/__tests__/map-fixtures.test.ts | 7 +- src/lib/whoop/__tests__/sync-body.test.ts | 2 +- .../whoop/__tests__/sync-sleep-sweep.test.ts | 2 +- src/lib/whoop/__tests__/sync-workout.test.ts | 2 +- .../__tests__/webhook-notification.test.ts | 5 +- src/lib/whoop/client.ts | 582 +------- src/lib/whoop/map.ts | 441 ++++++ src/lib/whoop/sync-body.ts | 3 +- src/lib/whoop/sync-cycle.ts | 3 +- src/lib/whoop/sync-recovery.ts | 2 +- src/lib/whoop/sync-sleep.ts | 3 +- src/lib/whoop/sync-workout.ts | 9 +- src/lib/whoop/webhook-handler.ts | 2 +- src/lib/whoop/wire-types.ts | 124 ++ .../google-health-backfill-multi-year.test.ts | 4 +- .../google-health-backfill-rate-limit.test.ts | 2 +- ...oogle-health-dense-backfill-memory.test.ts | 5 +- .../google-health-workout-terminal.test.ts | 7 +- tests/integration/strava-sync.test.ts | 2 +- 78 files changed, 3651 insertions(+), 3489 deletions(-) create mode 100644 src/lib/google-health/budget.ts create mode 100644 src/lib/google-health/data-points.ts create mode 100644 src/lib/google-health/request.ts create mode 100644 src/lib/oura/map.ts create mode 100644 src/lib/oura/wire-types.ts create mode 100644 src/lib/polar/map.ts create mode 100644 src/lib/polar/wire-types.ts create mode 100644 src/lib/strava/map.ts create mode 100644 src/lib/strava/wire-types.ts create mode 100644 src/lib/whoop/map.ts create mode 100644 src/lib/whoop/wire-types.ts diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 728d41357..f71967a10 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": 116, - "total": 136568, + "count": 113, + "total": 132997, "entries": { "app/api/dashboard/summary/route.ts": 1032, "app/api/documents/inbound/route.ts": 811, @@ -73,7 +73,6 @@ "lib/fitbit/sync-core.ts": 951, "lib/gamification/achievements-result.ts": 1094, "lib/gamification/achievements.ts": 913, - "lib/google-health/client.ts": 1589, "lib/google-health/mappers.ts": 1295, "lib/google-health/sync-core.ts": 1621, "lib/idempotency.ts": 879, @@ -107,9 +106,7 @@ "lib/medications/scheduling/recurrence.ts": 1108, "lib/medications/scheduling/slot-upsert.ts": 926, "lib/notifications/senders/apns.ts": 1100, - "lib/oura/client.ts": 1089, "lib/oura/sync.ts": 900, - "lib/polar/client.ts": 893, "lib/rollups/measurement-rollups.ts": 1496, "lib/search/search-records.ts": 996, "lib/sharing/grants.ts": 1046, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index d7f5955f3..0144c7916 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -326,8 +326,8 @@ "lib/fhir/build-bundle.ts :: buildFhirDocumentBundle": 363, "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/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, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index c65b301db..ad663138c 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": 2921, - "total": 10169, + "count": 2919, + "total": 10165, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -2079,7 +2079,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, @@ -2899,7 +2898,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, 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/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 index 4ddd7320f..289a099fc 100644 --- a/src/lib/google-health/__tests__/map-fixtures.test.ts +++ b/src/lib/google-health/__tests__/map-fixtures.test.ts @@ -51,13 +51,15 @@ import { buildDailyRollUpBody, chunkCivilRange, civilDateInTz, + resolveGoogleHealthUserId, +} from "../client"; +import { refusesEdgeFilter } from "@/lib/google-health/data-points"; +import { extractGoogleApiErrorDetail, formatCivilBound, googleHealthRetryDelayMs, incrementalFilter, - refusesEdgeFilter, - resolveGoogleHealthUserId, -} from "../client"; +} from "@/lib/google-health/request"; import dailySummariesJson from "./__fixtures__/daily-summaries.json"; import exerciseJson from "./__fixtures__/exercise.json"; import rollupsJson from "./__fixtures__/rollups.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__/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-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-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-time-budget.test.ts b/src/lib/google-health/__tests__/sync-time-budget.test.ts index 0ed7cebb0..0925858b9 100644 --- a/src/lib/google-health/__tests__/sync-time-budget.test.ts +++ b/src/lib/google-health/__tests__/sync-time-budget.test.ts @@ -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-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 b731f9597..0228a7a53 100644 --- a/src/lib/google-health/client.ts +++ b/src/lib/google-health/client.ts @@ -1,329 +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 { 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, } from "./response-classifier"; import { - googleHealthSampleInstant, - type GoogleHealthDataPoint, type GoogleHealthDataType, type GoogleHealthRollupPoint, } from "./mappers"; -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"; +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 @@ -396,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/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/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 e32e3b5f9..44f200e28 100644 --- a/src/lib/google-health/sync-core.ts +++ b/src/lib/google-health/sync-core.ts @@ -40,6 +40,8 @@ import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit import { recordSyncFailure, type FailureKind } from "@/lib/integrations/status"; 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, @@ -48,9 +50,7 @@ import { noteGoogleHealthMapped, noteGoogleHealthOutcomeFailure, noteGoogleHealthWritten, - type GoogleHealthResumePoint, - type GoogleHealthTokenSource, -} from "./client"; +} from "./budget"; import { GOOGLE_HEALTH_TOKEN_REFRESH_BUFFER_MS, googleHealthTokenDescriptor, 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 b26f32416..02676d189 100644 --- a/src/lib/google-health/sync.ts +++ b/src/lib/google-health/sync.ts @@ -9,7 +9,7 @@ import { runWithGoogleHealthClientOutcome, runWithGoogleHealthStop, type GoogleHealthClientOutcome, -} from "./client"; +} from "./budget"; import { syncUserActivity } from "./sync-activity"; import { syncUserMetrics } from "./sync-metrics"; import { syncUserSleep } from "./sync-sleep"; 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/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/google-health-backfill.ts b/src/lib/jobs/google-health-backfill.ts index 94d44bb9e..e8b816cfb 100644 --- a/src/lib/jobs/google-health-backfill.ts +++ b/src/lib/jobs/google-health-backfill.ts @@ -40,7 +40,7 @@ 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"; diff --git a/src/lib/oura/__tests__/client.test.ts b/src/lib/oura/__tests__/client.test.ts index e5715afd2..05f49c478 100644 --- a/src/lib/oura/__tests__/client.test.ts +++ b/src/lib/oura/__tests__/client.test.ts @@ -1,13 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { - RESILIENCE_LEVELS, - derivePeriodDaysFromCyclePhases, fetchCardiovascularAge, fetchDailyCyclePhases, fetchReadiness, fetchResilience, fetchVo2Max, +} from "../client"; +import { + RESILIENCE_LEVELS, + derivePeriodDaysFromCyclePhases, mapCardiovascularAge, mapDailyActivity, mapDailySleep, @@ -16,6 +18,8 @@ import { mapResilience, mapSleep, mapVo2Max, +} from "@/lib/oura/map"; +import { type OuraCardiovascularAge, type OuraDailyActivity, type OuraDailySleep, @@ -24,7 +28,7 @@ import { type OuraResilience, type OuraSleep, type OuraVo2Max, -} from "../client"; +} from "@/lib/oura/wire-types"; import { OURA_OAUTH_LEGACY_TOKEN_URL, OURA_OAUTH_TOKEN_URL, diff --git a/src/lib/oura/__tests__/map-fixtures.test.ts b/src/lib/oura/__tests__/map-fixtures.test.ts index aa808dbae..0e7ea9954 100644 --- a/src/lib/oura/__tests__/map-fixtures.test.ts +++ b/src/lib/oura/__tests__/map-fixtures.test.ts @@ -29,6 +29,8 @@ import { mapSleep, mapVo2Max, type MappedMeasurement, +} from "@/lib/oura/map"; +import { type OuraCardiovascularAge, type OuraCyclePhase, type OuraDailyActivity, @@ -40,7 +42,7 @@ import { type OuraSleep, type OuraVo2Max, type OuraWorkout, -} from "../client"; +} from "@/lib/oura/wire-types"; import { mapOuraWorkout } from "../sync-workouts"; import cardiovascularAgeFixture from "./__fixtures__/cardiovascular-age.json"; diff --git a/src/lib/oura/__tests__/workouts-heart-rate.test.ts b/src/lib/oura/__tests__/workouts-heart-rate.test.ts index 9d2bd56ea..ba70242bd 100644 --- a/src/lib/oura/__tests__/workouts-heart-rate.test.ts +++ b/src/lib/oura/__tests__/workouts-heart-rate.test.ts @@ -37,7 +37,7 @@ 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 c2561df67..4696679bc 100644 --- a/src/lib/oura/client.ts +++ b/src/lib/oura/client.ts @@ -1,191 +1,35 @@ /** - * Oura Cloud API v2 client: collection reads and mappers. + * 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). * - * Hand-rolled fetch over `safeFetch` (no SDK): typed v2 collection fetchers - * with `next_token` pagination and a single source-of-truth field→Measurement - * mapping. Collections are read by `start_date` / `end_date` (YYYY-MM-DD). - * The OAuth handshake is `./oauth.ts`. + * 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 { importValue } from "@/lib/integrations/import-value"; + import { walkPages } from "@/lib/integrations/page-walk"; import { retryAfterMsOf } from "@/lib/integrations/oauth-kit/retry-after"; 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"; -// ─── 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 @@ -492,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, @@ -526,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 @@ -553,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/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/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 804b15e06..3f8a901d4 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -69,6 +69,8 @@ import { fetchSleep, fetchVo2Max, fetchWorkouts, +} from "./client"; +import { isRetiredOuraSleepPeriod, mapCardiovascularAge, mapDailyActivity, @@ -79,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"; @@ -883,10 +885,8 @@ export async function upsertOuraMeasurements( } opts.onInserted?.(insertedRows); - // The shared tail: arrivals (unless the caller emits its own), 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. + // 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", 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__/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 99617d6d3..80b331b0a 100644 --- a/src/lib/polar/__tests__/client.test.ts +++ b/src/lib/polar/__tests__/client.test.ts @@ -12,18 +12,22 @@ import { fetchNightlyRecharges, fetchSleeps, fetchSpo2, + 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"; diff --git a/src/lib/polar/__tests__/map-fixtures.test.ts b/src/lib/polar/__tests__/map-fixtures.test.ts index 1cbe10126..c52c3fd59 100644 --- a/src/lib/polar/__tests__/map-fixtures.test.ts +++ b/src/lib/polar/__tests__/map-fixtures.test.ts @@ -24,13 +24,15 @@ import { mapSpo2, normaliseExerciseList, parseIsoDuration, +} from "@/lib/polar/map"; +import { type PolarActivity, type PolarCardioLoad, type PolarExercise, type PolarNightlyRecharge, type PolarSleep, type PolarSpo2, -} from "../client"; +} from "@/lib/polar/wire-types"; import activityFixture from "./__fixtures__/activity.json"; import cardioLoadFixture from "./__fixtures__/cardio-load.json"; import exercisesFixture from "./__fixtures__/exercises.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/client.ts b/src/lib/polar/client.ts index 4eb151cb9..bd7617701 100644 --- a/src/lib/polar/client.ts +++ b/src/lib/polar/client.ts @@ -1,12 +1,14 @@ /** - * Polar AccessLink v3 client: user registration, collection reads, mappers. + * 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. * - * Hand-rolled fetch over `safeFetch` (no SDK): typed collection fetchers and - * per-resource field→Measurement mappers. The OAuth handshake is `./oauth.ts`. + * 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)`) @@ -22,14 +24,20 @@ * 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 { 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"; @@ -75,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[]; } @@ -281,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 / @@ -716,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/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/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 f48aafcb1..10c6976e8 100644 --- a/src/lib/polar/sync.ts +++ b/src/lib/polar/sync.ts @@ -68,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, 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/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 index 954ba650b..b5624ef29 100644 --- a/src/lib/strava/__tests__/map-fixtures.test.ts +++ b/src/lib/strava/__tests__/map-fixtures.test.ts @@ -15,13 +15,12 @@ import { format } from "prettier"; import { describe, expect, it } from "vitest"; +import { stravaRateLimitRetryAfterSeconds } from "../client"; +import { mapActivity, summaryHasHeartRate } from "@/lib/strava/map"; import { - mapActivity, - stravaRateLimitRetryAfterSeconds, - summaryHasHeartRate, type StravaDetailedActivity, type StravaSummaryActivity, -} from "../client"; +} from "@/lib/strava/wire-types"; import { mapStravaSportType } from "../sport-map"; import activitiesFixture from "./__fixtures__/activities.json"; diff --git a/src/lib/strava/client.ts b/src/lib/strava/client.ts index 6b5fd33a8..cc5ed7249 100644 --- a/src/lib/strava/client.ts +++ b/src/lib/strava/client.ts @@ -1,10 +1,10 @@ /** - * Strava API v3 client: activity reads. + * Strava API v3 transport and endpoints: activity reads. * Docs: https://developers.strava.com/docs/reference/ (re-verify at build). * - * Hand-rolled fetch over `safeFetch` (no vendor SDK): typed collection - * fetchers and a single field → `Workout` mapper. The OAuth handshake is - * `./oauth.ts`. + * 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 @@ -23,48 +23,16 @@ 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 } from "./response-classifier"; -import { mapStravaSportType } from "./sport-map"; -import type { WorkoutSportType } from "@/lib/validations/workout"; + +import type { + StravaSummaryActivity, + StravaDetailedActivity, +} from "./wire-types"; const STRAVA_API_BASE = "https://www.strava.com/api/v3"; -// ─── 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. */ after?: number; @@ -183,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/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/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/whoop/__tests__/client.test.ts b/src/lib/whoop/__tests__/client.test.ts index b935e8a2c..eb83dfb5f 100644 --- a/src/lib/whoop/__tests__/client.test.ts +++ b/src/lib/whoop/__tests__/client.test.ts @@ -1,7 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { - KJ_TO_KCAL, - WHOOP_FIELD_MAP, WHOOP_PAGE_LIMIT, fetchBodyMeasurement, fetchRecoveryByCycleId, @@ -13,13 +11,19 @@ import { fetchRecoveries, fetchSleeps, fetchWorkouts, +} from "../client"; +import { + KJ_TO_KCAL, + WHOOP_FIELD_MAP, mapCycle, mapRecovery, mapSleep, +} 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"; diff --git a/src/lib/whoop/__tests__/map-fixtures.test.ts b/src/lib/whoop/__tests__/map-fixtures.test.ts index 590fae371..fe2506787 100644 --- a/src/lib/whoop/__tests__/map-fixtures.test.ts +++ b/src/lib/whoop/__tests__/map-fixtures.test.ts @@ -14,16 +14,13 @@ import { format } from "prettier"; import { describe, expect, it } from "vitest"; +import { mapBody, mapCycle, mapRecovery, mapSleep } from "@/lib/whoop/map"; import { - mapBody, - mapCycle, - mapRecovery, - mapSleep, type WhoopBodyMeasurement, type WhoopCycle, type WhoopRecovery, type WhoopSleep, -} from "../client"; +} from "@/lib/whoop/wire-types"; import { mapWhoopSportType } from "../sport-map"; import bodyFixture from "./__fixtures__/body.json"; import cycleFixture from "./__fixtures__/cycle.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-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-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__/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 70f3aa859..0117dbf80 100644 --- a/src/lib/whoop/client.ts +++ b/src/lib/whoop/client.ts @@ -1,152 +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). * - * Hand-rolled fetch over `safeFetch` (no SDK): 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`). - * The OAuth handshake is `./oauth.ts`. - * - * 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 { 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"; /** WHOOP collection reads cap `limit` at 25. */ export const WHOOP_PAGE_LIMIT = 25; -// ─── 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; @@ -362,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/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/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-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 a3c05e829..63cdbc3f1 100644 --- a/src/lib/whoop/webhook-handler.ts +++ b/src/lib/whoop/webhook-handler.ts @@ -33,7 +33,7 @@ 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, 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/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/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"; From 8b3c095df55e0d83029d9e6e13d723501128b4f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 13:33:11 +0200 Subject: [PATCH 09/10] refactor(fitbit): keep the Fitbit jobs inside the Fitbit folder The backfill, the sleep repair, the OAuth-state cleanup and the sync handlers move from lib/jobs into lib/fitbit/jobs, beside the queue facts and definitions the registry already spreads. Their tests move with them. Removing the integration is now the folder, its routes, the registry spread and the drain's two cases. The updatedAt backfill guard also walks provider jobs folders, so the moved backfill stays covered. --- .../backfill-preserves-updated-at-guard.test.ts | 14 +++++++++++--- src/__tests__/baselines/test-source-reads.json | 2 +- .../baselines/version-stamp-comments.json | 8 ++++---- .../jobs/__tests__/backfill.test.ts} | 2 +- .../jobs/__tests__/queues.test.ts} | 4 ++-- .../jobs/__tests__/sleep-repair.test.ts} | 2 +- .../jobs/__tests__/sync-retired.test.ts} | 2 +- .../jobs/backfill.ts} | 2 +- src/lib/fitbit/jobs/definitions.ts | 16 ++++++---------- .../jobs/oauth-state-cleanup.ts} | 0 .../jobs/sleep-repair.ts} | 2 +- .../jobs/sync-handlers.ts} | 4 ++-- .../integration-backfill-admission.test.ts | 12 ++++++------ src/lib/jobs/google-health-backfill.ts | 2 +- src/lib/jobs/integration-backfill-drain.ts | 4 ++-- src/lib/jobs/sleep-timeline-backfill.ts | 2 +- 16 files changed, 41 insertions(+), 37 deletions(-) rename src/lib/{jobs/__tests__/fitbit-backfill.test.ts => fitbit/jobs/__tests__/backfill.test.ts} (99%) rename src/lib/{jobs/__tests__/fitbit-queues.test.ts => fitbit/jobs/__tests__/queues.test.ts} (97%) rename src/lib/{jobs/__tests__/fitbit-sleep-repair.test.ts => fitbit/jobs/__tests__/sleep-repair.test.ts} (99%) rename src/lib/{jobs/__tests__/fitbit-sync-retired.test.ts => fitbit/jobs/__tests__/sync-retired.test.ts} (95%) rename src/lib/{jobs/fitbit-backfill.ts => fitbit/jobs/backfill.ts} (98%) rename src/lib/{jobs/fitbit-oauth-state-cleanup.ts => fitbit/jobs/oauth-state-cleanup.ts} (100%) rename src/lib/{jobs/fitbit-sleep-repair.ts => fitbit/jobs/sleep-repair.ts} (99%) rename src/lib/{jobs/reminder/fitbit-sync.ts => fitbit/jobs/sync-handlers.ts} (97%) 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/test-source-reads.json b/src/__tests__/baselines/test-source-reads.json index 309eef2f2..1a976c5f6 100644 --- a/src/__tests__/baselines/test-source-reads.json +++ b/src/__tests__/baselines/test-source-reads.json @@ -320,7 +320,7 @@ "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__/fitbit-queues.test.ts": 1, + "lib/fitbit/jobs/__tests__/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, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index ad663138c..46f890774 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -2292,7 +2292,7 @@ "lib/jobs/__tests__/coach-reminder-sweep.test.ts": 1, "lib/jobs/__tests__/cycle-reminder.test.ts": 2, "lib/jobs/__tests__/feedback-aggregator.test.ts": 2, - "lib/jobs/__tests__/fitbit-backfill.test.ts": 1, + "lib/fitbit/jobs/__tests__/backfill.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, @@ -2336,8 +2336,8 @@ "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/fitbit/jobs/backfill.ts": 1, + "lib/fitbit/jobs/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, @@ -2374,7 +2374,7 @@ "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/fitbit/jobs/sync-handlers.ts": 2, "lib/jobs/reminder/google-health-sync.ts": 1, "lib/jobs/reminder/insights-handlers.ts": 3, "lib/jobs/reminder/medication-maintenance.ts": 3, 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/jobs/__tests__/fitbit-queues.test.ts b/src/lib/fitbit/jobs/__tests__/queues.test.ts similarity index 97% rename from src/lib/jobs/__tests__/fitbit-queues.test.ts rename to src/lib/fitbit/jobs/__tests__/queues.test.ts index 988c8fcf8..6f6db73f9 100644 --- a/src/lib/jobs/__tests__/fitbit-queues.test.ts +++ b/src/lib/fitbit/jobs/__tests__/queues.test.ts @@ -29,7 +29,7 @@ import { INTEGRATION_BACKFILL_ADMISSION_QUEUE } from "@/lib/jobs/integration-bac import { handleFitbitOAuthStateCleanup, handleFitbitSync, -} from "@/lib/jobs/reminder/fitbit-sync"; +} from "@/lib/fitbit/jobs/sync-handlers"; import { cronIsTheRetry } from "@/lib/jobs/reminder/registrar-shared"; import { discoveryOf, @@ -40,7 +40,7 @@ import { } from "@/lib/jobs/registry/__tests__/registry-helpers"; const handlerSource = readFileSync( - join(__dirname, "..", "reminder", "fitbit-sync.ts"), + join(__dirname, "..", "sync-handlers.ts"), "utf8", ); diff --git a/src/lib/jobs/__tests__/fitbit-sleep-repair.test.ts b/src/lib/fitbit/jobs/__tests__/sleep-repair.test.ts similarity index 99% rename from src/lib/jobs/__tests__/fitbit-sleep-repair.test.ts rename to src/lib/fitbit/jobs/__tests__/sleep-repair.test.ts index 23922aee7..374c6fe43 100644 --- a/src/lib/jobs/__tests__/fitbit-sleep-repair.test.ts +++ b/src/lib/fitbit/jobs/__tests__/sleep-repair.test.ts @@ -76,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 98% rename from src/lib/jobs/fitbit-backfill.ts rename to src/lib/fitbit/jobs/backfill.ts index 02f84fa21..69d81a05f 100644 --- a/src/lib/jobs/fitbit-backfill.ts +++ b/src/lib/fitbit/jobs/backfill.ts @@ -21,7 +21,7 @@ import { } from "@/lib/integrations/page-walk"; import { syncUserFitbit } from "@/lib/fitbit/sync"; import { isFitbitRetired } from "@/lib/fitbit/retirement"; -import { FITBIT_BACKFILL_QUEUE } from "./../fitbit/jobs/queues"; +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 index a4308c82d..c01d6f31e 100644 --- a/src/lib/fitbit/jobs/definitions.ts +++ b/src/lib/fitbit/jobs/definitions.ts @@ -33,8 +33,7 @@ export const FITBIT_JOBS: readonly JobDefinition[] = [ queue: FITBIT_SYNC_QUEUE, cron: FITBIT_SYNC_CRON, work: { localConcurrency: 1 }, - load: async () => - (await import("@/lib/jobs/reminder/fitbit-sync")).handleFitbitSync, + load: async () => (await import("./sync-handlers")).handleFitbitSync, }), // The source queue keeps the per-connection singleton and forwards // admitted work to the shared drain. @@ -72,8 +71,7 @@ export const FITBIT_JOBS: readonly JobDefinition[] = [ scheduleOptions: cronIsTheRetry, work: { localConcurrency: 1 }, load: async () => - (await import("@/lib/jobs/reminder/fitbit-sync")) - .handleFitbitOAuthStateCleanup, + (await import("./sync-handlers")).handleFitbitOAuthStateCleanup, }), ]; @@ -82,16 +80,14 @@ export const FITBIT_BOOT_DISCOVERY: readonly BootDiscovery[] = [ { name: "fitbit-backfill", run: async () => - ( - await import("@/lib/jobs/fitbit-backfill") - ).enqueueBootTimeFitbitBackfill(bootStaggerSecondsFor("fitbit-backfill")), + (await import("./backfill")).enqueueBootTimeFitbitBackfill( + bootStaggerSecondsFor("fitbit-backfill"), + ), }, { name: "fitbit-sleep-repair", run: async () => - ( - await import("@/lib/jobs/fitbit-sleep-repair") - ).enqueueBootTimeFitbitSleepRepair( + (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/jobs/fitbit-sleep-repair.ts b/src/lib/fitbit/jobs/sleep-repair.ts similarity index 99% rename from src/lib/jobs/fitbit-sleep-repair.ts rename to src/lib/fitbit/jobs/sleep-repair.ts index f79ebb8f2..f4919a803 100644 --- a/src/lib/jobs/fitbit-sleep-repair.ts +++ b/src/lib/fitbit/jobs/sleep-repair.ts @@ -46,7 +46,7 @@ import { } from "@/lib/fitbit/sync-core"; import { syncUserSleep } from "@/lib/fitbit/sync-sleep"; import { isFitbitRetired } from "@/lib/fitbit/retirement"; -import { FITBIT_SLEEP_REPAIR_QUEUE } from "./../fitbit/jobs/queues"; +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/jobs/__tests__/integration-backfill-admission.test.ts b/src/lib/jobs/__tests__/integration-backfill-admission.test.ts index 69ac042a1..32126000b 100644 --- a/src/lib/jobs/__tests__/integration-backfill-admission.test.ts +++ b/src/lib/jobs/__tests__/integration-backfill-admission.test.ts @@ -20,8 +20,8 @@ vi.mock("@/lib/jobs/whoop-backfill", async (importOriginal) => ({ enqueueBootTimeWhoopBackfill: async (stagger: number) => recordStagger("whoop-backfill", stagger), })); -vi.mock("@/lib/jobs/fitbit-backfill", async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock("@/lib/fitbit/jobs/backfill", async (importOriginal) => ({ + ...(await importOriginal()), enqueueBootTimeFitbitBackfill: async (stagger: number) => recordStagger("fitbit-backfill", stagger), })); @@ -58,8 +58,8 @@ vi.mock("@/lib/jobs/strava-backfill", async (importOriginal) => ({ enqueueBootTimeStravaBackfill: async (stagger: number) => recordStagger("strava-backfill", stagger), })); -vi.mock("@/lib/jobs/fitbit-sleep-repair", async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock("@/lib/fitbit/jobs/sleep-repair", async (importOriginal) => ({ + ...(await importOriginal()), enqueueBootTimeFitbitSleepRepair: async (stagger: number) => recordStagger("fitbit-sleep-repair", stagger), })); @@ -105,13 +105,13 @@ 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")); diff --git a/src/lib/jobs/google-health-backfill.ts b/src/lib/jobs/google-health-backfill.ts index e8b816cfb..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). 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/sleep-timeline-backfill.ts b/src/lib/jobs/sleep-timeline-backfill.ts index 2adbfa5d5..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). From c2a63307ed276e81988dae767243bc81a557283a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 13:44:21 +0200 Subject: [PATCH 10/10] test: point guards at the split provider modules The page-walk exception for the Google Health list walk follows forEachDataPointPage into data-points.ts, and the workout provenance guard reads the raw Polar and Strava sport labels where the mappers now live. The write-step helper's tests assert the result it returns, including a failed fold and a caller that re-warms the status cards itself. --- src/__tests__/page-walk-guard.test.ts | 2 +- .../workout-sport-provenance-guard.test.ts | 4 +-- .../after-measurement-mutation.test.ts | 30 ++++++++++++++++--- 3 files changed, 29 insertions(+), 7 deletions(-) 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__/workout-sport-provenance-guard.test.ts b/src/__tests__/workout-sport-provenance-guard.test.ts index 280c7fe55..f7aecd2e6 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/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 {